mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 06:37:41 +00:00 
			
		
		
		
	Compare commits
	
		
			90 Commits
		
	
	
		
			post-reque
			...
			minor-queu
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 30515c0e9f | ||
|   | 4bda1a234f | ||
|   | d297850539 | ||
|   | 751239250f | ||
|   | 6aceeb01ab | ||
|   | 49bc982c69 | ||
|   | e0abf0b505 | ||
|   | f08a1185aa | ||
|   | ad5d7efbbf | ||
|   | 7029d10f8b | ||
|   | 26d3a23e05 | ||
|   | 942625e1fb | ||
|   | 33c83230a6 | ||
|   | 87510becb5 | ||
|   | 5e95dc62a5 | ||
|   | 7d94535dbf | ||
|   | 563c196396 | ||
|   | e8b82c47ca | ||
|   | e84de7e8f4 | ||
|   | 1543edca24 | ||
|   | 82e0b99b07 | ||
|   | b0ff9d161e | ||
|   | c1dd681643 | ||
|   | ecafa27833 | ||
|   | f7d4e58613 | ||
|   | 5bb47e47db | ||
|   | 03151da68e | ||
|   | a16a70229d | ||
|   | 9476c1076b | ||
|   | a4959b5971 | ||
|   | a278fa22f2 | ||
|   | d39530b261 | ||
|   | d4b4355ff5 | ||
|   | c1c8de3104 | ||
|   | 5a768d7db3 | ||
|   | f38429ec93 | ||
|   | 783926962d | ||
|   | 6cd1d50a4f | ||
|   | 54a4970a4c | ||
|   | fd00453e6d | ||
|   | 2842ffb205 | ||
|   | ec4e2f5649 | ||
|   | fe8e3d1cb1 | ||
|   | 69fbafbdb7 | ||
|   | f255165571 | ||
|   | 7ff34baa90 | ||
|   | 043378d09c | ||
|   | af4bafcff8 | ||
|   | b656338c63 | ||
|   | 97af190910 | ||
|   | e9e063e18e | ||
|   | 45c444d0db | ||
|   | 00458b95c4 | ||
|   | dad9760832 | ||
|   | e2c2a76cb2 | ||
|   | 5b34aece96 | ||
|   | 1b625dc18a | ||
|   | 367afc81e9 | ||
|   | ddfbef6db3 | ||
|   | e173954cdd | ||
|   | e830fb2320 | ||
|   | c6589ee1b4 | ||
|   | dc936a2e8a | ||
|   | 8c1527c1ad | ||
|   | a5ff1cd1d7 | ||
|   | 543cb205d2 | ||
|   | 273adfa0a4 | ||
|   | 8ecfd17973 | ||
|   | 19f3851c9d | ||
|   | 7f2fa20318 | ||
|   | e16814e40b | ||
|   | 337fcab3f1 | ||
|   | eaccd6026c | ||
|   | 5b70625eaa | ||
|   | 60d292107d | ||
|   | 1cb38347da | ||
|   | 55fe2abf42 | ||
|   | 4225900ec3 | ||
|   | 1fb4342488 | ||
|   | 7071df061a | ||
|   | 6dd1fa2b88 | ||
|   | 371f85d544 | ||
|   | 932cf15e1e | ||
|   | bf0d410d32 | ||
|   | 730f37c7ba | ||
|   | 8a35d62e02 | ||
|   | f527744024 | ||
|   | 71c9b1273c | ||
|   | ec68450df1 | ||
|   | 2fd762a783 | 
							
								
								
									
										4
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							| @@ -27,6 +27,10 @@ A clear and concise description of what the bug is. | ||||
| **Version** | ||||
| *Exact version* in the top right area: 0.... | ||||
|  | ||||
| **How did you install?** | ||||
|  | ||||
| Docker, Pip, from source directly etc | ||||
|  | ||||
| **To Reproduce** | ||||
|  | ||||
| Steps to reproduce the behavior: | ||||
|   | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -10,5 +10,6 @@ dist | ||||
| venv | ||||
| test-datastore/* | ||||
| test-datastore | ||||
| test-memory.log | ||||
| *.egg-info* | ||||
| .vscode/settings.json | ||||
|   | ||||
| @@ -37,6 +37,7 @@ RUN pip install --target=/dependencies playwright~=1.41.2 \ | ||||
|  | ||||
| # Final image stage | ||||
| FROM python:${PYTHON_VERSION}-slim-bookworm | ||||
| LABEL org.opencontainers.image.source="https://github.com/dgtlmoon/changedetection.io" | ||||
|  | ||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
|     libxslt1.1 \ | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| recursive-include changedetectionio/api * | ||||
| recursive-include changedetectionio/apprise_plugin * | ||||
| recursive-include changedetectionio/blueprint * | ||||
| recursive-include changedetectionio/content_fetchers * | ||||
| recursive-include changedetectionio/model * | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| # Read more https://github.com/dgtlmoon/changedetection.io/wiki | ||||
|  | ||||
| __version__ = '0.46.01' | ||||
| __version__ = '0.47.06' | ||||
|  | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from json.decoder import JSONDecodeError | ||||
|   | ||||
| @@ -58,7 +58,7 @@ class Watch(Resource): | ||||
|             abort(404, message='No watch exists with the UUID of {}'.format(uuid)) | ||||
|  | ||||
|         if request.args.get('recheck'): | ||||
|             self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True})) | ||||
|             self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|             return "OK", 200 | ||||
|         if request.args.get('paused', '') == 'paused': | ||||
|             self.datastore.data['watching'].get(uuid).pause() | ||||
| @@ -246,7 +246,7 @@ class CreateWatch(Resource): | ||||
|  | ||||
|         new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags) | ||||
|         if new_uuid: | ||||
|             self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid, 'skip_when_checksum_same': True})) | ||||
|             self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid})) | ||||
|             return {'uuid': new_uuid}, 201 | ||||
|         else: | ||||
|             return "Invalid or unsupported URL", 400 | ||||
| @@ -303,7 +303,7 @@ class CreateWatch(Resource): | ||||
|  | ||||
|         if request.args.get('recheck_all'): | ||||
|             for uuid in self.datastore.data['watching'].keys(): | ||||
|                 self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True})) | ||||
|                 self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|             return {'status': "OK"}, 200 | ||||
|  | ||||
|         return list, 200 | ||||
|   | ||||
							
								
								
									
										82
									
								
								changedetectionio/apprise_plugin/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								changedetectionio/apprise_plugin/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| # include the decorator | ||||
| from apprise.decorators import notify | ||||
| from loguru import logger | ||||
|  | ||||
| @notify(on="delete") | ||||
| @notify(on="deletes") | ||||
| @notify(on="get") | ||||
| @notify(on="gets") | ||||
| @notify(on="post") | ||||
| @notify(on="posts") | ||||
| @notify(on="put") | ||||
| @notify(on="puts") | ||||
| def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs): | ||||
|     import requests | ||||
|     import json | ||||
|     from urllib.parse import unquote_plus | ||||
|     from apprise.utils import parse_url as apprise_parse_url | ||||
|     from apprise import URLBase | ||||
|  | ||||
|     url = kwargs['meta'].get('url') | ||||
|  | ||||
|     if url.startswith('post'): | ||||
|         r = requests.post | ||||
|     elif url.startswith('get'): | ||||
|         r = requests.get | ||||
|     elif url.startswith('put'): | ||||
|         r = requests.put | ||||
|     elif url.startswith('delete'): | ||||
|         r = requests.delete | ||||
|  | ||||
|     url = url.replace('post://', 'http://') | ||||
|     url = url.replace('posts://', 'https://') | ||||
|     url = url.replace('put://', 'http://') | ||||
|     url = url.replace('puts://', 'https://') | ||||
|     url = url.replace('get://', 'http://') | ||||
|     url = url.replace('gets://', 'https://') | ||||
|     url = url.replace('put://', 'http://') | ||||
|     url = url.replace('puts://', 'https://') | ||||
|     url = url.replace('delete://', 'http://') | ||||
|     url = url.replace('deletes://', 'https://') | ||||
|  | ||||
|     headers = {} | ||||
|     params = {} | ||||
|     auth = None | ||||
|  | ||||
|     # Convert /foobar?+some-header=hello to proper header dictionary | ||||
|     results = apprise_parse_url(url) | ||||
|     if results: | ||||
|         # Add our headers that the user can potentially over-ride if they wish | ||||
|         # to to our returned result set and tidy entries by unquoting them | ||||
|         headers = {unquote_plus(x): unquote_plus(y) | ||||
|                    for x, y in results['qsd+'].items()} | ||||
|  | ||||
|         # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation | ||||
|         # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise | ||||
|         # but here we are making straight requests, so we need todo convert this against apprise's logic | ||||
|         for k, v in results['qsd'].items(): | ||||
|             if not k.strip('+-') in results['qsd+'].keys(): | ||||
|                 params[unquote_plus(k)] = unquote_plus(v) | ||||
|  | ||||
|         # Determine Authentication | ||||
|         auth = '' | ||||
|         if results.get('user') and results.get('password'): | ||||
|             auth = (unquote_plus(results.get('user')), unquote_plus(results.get('user'))) | ||||
|         elif results.get('user'): | ||||
|             auth = (unquote_plus(results.get('user'))) | ||||
|  | ||||
|     # Try to auto-guess if it's JSON | ||||
|     h = 'application/json; charset=utf-8' | ||||
|     try: | ||||
|         json.loads(body) | ||||
|         headers['Content-Type'] = h | ||||
|     except ValueError as e: | ||||
|         logger.warning(f"Could not automatically add '{h}' header to the {kwargs['meta'].get('schema')}:// notification because the document failed to parse as JSON: {e}") | ||||
|         pass | ||||
|  | ||||
|     r(results.get('url'), | ||||
|       auth=auth, | ||||
|       data=body.encode('utf-8') if type(body) is str else body, | ||||
|       headers=headers, | ||||
|       params=params | ||||
|       ) | ||||
							
								
								
									
										164
									
								
								changedetectionio/blueprint/backups/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								changedetectionio/blueprint/backups/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,164 @@ | ||||
| import datetime | ||||
| import glob | ||||
| import threading | ||||
|  | ||||
| from flask import Blueprint, render_template, send_from_directory, flash, url_for, redirect, abort | ||||
| import os | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.flask_app import login_optionally_required | ||||
| from loguru import logger | ||||
|  | ||||
| BACKUP_FILENAME_FORMAT = "changedetection-backup-{}.zip" | ||||
|  | ||||
|  | ||||
| def create_backup(datastore_path, watches: dict): | ||||
|     logger.debug("Creating backup...") | ||||
|     import zipfile | ||||
|     from pathlib import Path | ||||
|  | ||||
|     # create a ZipFile object | ||||
|     timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") | ||||
|     backupname = BACKUP_FILENAME_FORMAT.format(timestamp) | ||||
|     backup_filepath = os.path.join(datastore_path, backupname) | ||||
|  | ||||
|     with zipfile.ZipFile(backup_filepath.replace('.zip', '.tmp'), "w", | ||||
|                          compression=zipfile.ZIP_DEFLATED, | ||||
|                          compresslevel=8) as zipObj: | ||||
|  | ||||
|         # Add the index | ||||
|         zipObj.write(os.path.join(datastore_path, "url-watches.json"), arcname="url-watches.json") | ||||
|  | ||||
|         # Add the flask app secret | ||||
|         zipObj.write(os.path.join(datastore_path, "secret.txt"), arcname="secret.txt") | ||||
|  | ||||
|         # Add any data in the watch data directory. | ||||
|         for uuid, w in watches.items(): | ||||
|             for f in Path(w.watch_data_dir).glob('*'): | ||||
|                 zipObj.write(f, | ||||
|                              # Use the full path to access the file, but make the file 'relative' in the Zip. | ||||
|                              arcname=os.path.join(f.parts[-2], f.parts[-1]), | ||||
|                              compress_type=zipfile.ZIP_DEFLATED, | ||||
|                              compresslevel=8) | ||||
|  | ||||
|         # Create a list file with just the URLs, so it's easier to port somewhere else in the future | ||||
|         list_file = "url-list.txt" | ||||
|         with open(os.path.join(datastore_path, list_file), "w") as f: | ||||
|             for uuid in watches: | ||||
|                 url = watches[uuid]["url"] | ||||
|                 f.write("{}\r\n".format(url)) | ||||
|         list_with_tags_file = "url-list-with-tags.txt" | ||||
|         with open( | ||||
|                 os.path.join(datastore_path, list_with_tags_file), "w" | ||||
|         ) as f: | ||||
|             for uuid in watches: | ||||
|                 url = watches[uuid].get('url') | ||||
|                 tag = watches[uuid].get('tags', {}) | ||||
|                 f.write("{} {}\r\n".format(url, tag)) | ||||
|  | ||||
|         # Add it to the Zip | ||||
|         zipObj.write( | ||||
|             os.path.join(datastore_path, list_file), | ||||
|             arcname=list_file, | ||||
|             compress_type=zipfile.ZIP_DEFLATED, | ||||
|             compresslevel=8, | ||||
|         ) | ||||
|         zipObj.write( | ||||
|             os.path.join(datastore_path, list_with_tags_file), | ||||
|             arcname=list_with_tags_file, | ||||
|             compress_type=zipfile.ZIP_DEFLATED, | ||||
|             compresslevel=8, | ||||
|         ) | ||||
|  | ||||
|     # Now it's done, rename it so it shows up finally and its completed being written. | ||||
|     os.rename(backup_filepath.replace('.zip', '.tmp'), backup_filepath.replace('.tmp', '.zip')) | ||||
|  | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     backups_blueprint = Blueprint('backups', __name__, template_folder="templates") | ||||
|     backup_threads = [] | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @backups_blueprint.route("/request-backup", methods=['GET']) | ||||
|     def request_backup(): | ||||
|         if any(thread.is_alive() for thread in backup_threads): | ||||
|             flash("A backup is already running, check back in a few minutes", "error") | ||||
|             return redirect(url_for('backups.index')) | ||||
|  | ||||
|         if len(find_backups()) > int(os.getenv("MAX_NUMBER_BACKUPS", 100)): | ||||
|             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) | ||||
|         flash("Backup building in background, check back in a few minutes.") | ||||
|  | ||||
|         return redirect(url_for('backups.index')) | ||||
|  | ||||
|     def find_backups(): | ||||
|         backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*")) | ||||
|         backups = glob.glob(backup_filepath) | ||||
|         backup_info = [] | ||||
|  | ||||
|         for backup in backups: | ||||
|             size = os.path.getsize(backup) / (1024 * 1024) | ||||
|             creation_time = os.path.getctime(backup) | ||||
|             backup_info.append({ | ||||
|                 'filename': os.path.basename(backup), | ||||
|                 'filesize': f"{size:.2f}", | ||||
|                 'creation_time': creation_time | ||||
|             }) | ||||
|  | ||||
|         backup_info.sort(key=lambda x: x['creation_time'], reverse=True) | ||||
|  | ||||
|         return backup_info | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @backups_blueprint.route("/download/<string:filename>", methods=['GET']) | ||||
|     def download_backup(filename): | ||||
|         import re | ||||
|         filename = filename.strip() | ||||
|         backup_filename_regex = BACKUP_FILENAME_FORMAT.format("\d+") | ||||
|  | ||||
|         full_path = os.path.join(os.path.abspath(datastore.datastore_path), filename) | ||||
|         if not full_path.startswith(os.path.abspath(datastore.datastore_path)): | ||||
|             abort(404) | ||||
|  | ||||
|         if filename == 'latest': | ||||
|             backups = find_backups() | ||||
|             filename = backups[0]['filename'] | ||||
|  | ||||
|         if not re.match(r"^" + backup_filename_regex + "$", filename): | ||||
|             abort(400)  # Bad Request if the filename doesn't match the pattern | ||||
|  | ||||
|         logger.debug(f"Backup download request for '{full_path}'") | ||||
|         return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True) | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @backups_blueprint.route("/", methods=['GET']) | ||||
|     def index(): | ||||
|         backups = find_backups() | ||||
|         output = render_template("overview.html", | ||||
|                                  available_backups=backups, | ||||
|                                  backup_running=any(thread.is_alive() for thread in backup_threads) | ||||
|                                  ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @backups_blueprint.route("/remove-backups", methods=['GET']) | ||||
|     def remove_backups(): | ||||
|  | ||||
|         backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*")) | ||||
|         backups = glob.glob(backup_filepath) | ||||
|         for backup in backups: | ||||
|             os.unlink(backup) | ||||
|  | ||||
|         flash("Backups were deleted.") | ||||
|  | ||||
|         return redirect(url_for('backups.index')) | ||||
|  | ||||
|     return backups_blueprint | ||||
							
								
								
									
										36
									
								
								changedetectionio/blueprint/backups/templates/overview.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								changedetectionio/blueprint/backups/templates/overview.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
|     {% from '_helpers.html' import render_simple_field, render_field %} | ||||
|     <div class="edit-form"> | ||||
|         <div class="box-wrap inner"> | ||||
|             <h4>Backups</h4> | ||||
|             {% if backup_running %} | ||||
|                 <p> | ||||
|                     <strong>A backup is running!</strong> | ||||
|                 </p> | ||||
|             {% endif %} | ||||
|             <p> | ||||
|                 Here you can download and request a new backup, when a backup is completed you will see it listed below. | ||||
|             </p> | ||||
|             <br> | ||||
|                 {% if available_backups %} | ||||
|                     <ul> | ||||
|                     {% for backup in available_backups %} | ||||
|                         <li><a href="{{ url_for('backups.download_backup', filename=backup["filename"]) }}">{{ backup["filename"] }}</a> {{  backup["filesize"] }} Mb</li> | ||||
|                     {% endfor %} | ||||
|                     </ul> | ||||
|                 {% else %} | ||||
|                     <p> | ||||
|                     <strong>No backups found.</strong> | ||||
|                     </p> | ||||
|                 {% endif %} | ||||
|  | ||||
|             <a class="pure-button pure-button-primary" href="{{ url_for('backups.request_backup') }}">Create backup</a> | ||||
|             {% if available_backups %} | ||||
|                 <a class="pure-button button-small button-error " href="{{ url_for('backups.remove_backups') }}">Remove backups</a> | ||||
|             {% endif %} | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -85,7 +85,8 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|         browsersteps_start_session['browserstepper'] = browser_steps.browsersteps_live_ui( | ||||
|             playwright_browser=browsersteps_start_session['browser'], | ||||
|             proxy=proxy, | ||||
|             start_url=datastore.data['watching'][watch_uuid].get('url') | ||||
|             start_url=datastore.data['watching'][watch_uuid].get('url'), | ||||
|             headers=datastore.data['watching'][watch_uuid].get('headers') | ||||
|         ) | ||||
|  | ||||
|         # For test | ||||
|   | ||||
| @@ -25,6 +25,7 @@ browser_step_ui_config = {'Choose one': '0 0', | ||||
|                           'Click element if exists': '1 0', | ||||
|                           'Click element': '1 0', | ||||
|                           'Click element containing text': '0 1', | ||||
|                           'Click element containing text if exists': '0 1', | ||||
|                           'Enter text in field': '1 1', | ||||
|                           'Execute JS': '0 1', | ||||
| #                          'Extract text and use as filter': '1 0', | ||||
| @@ -96,12 +97,24 @@ class steppable_browser_interface(): | ||||
|         return self.action_goto_url(value=self.start_url) | ||||
|  | ||||
|     def action_click_element_containing_text(self, selector=None, value=''): | ||||
|         logger.debug("Clicking element containing text") | ||||
|         if not len(value.strip()): | ||||
|             return | ||||
|         elem = self.page.get_by_text(value) | ||||
|         if elem.count(): | ||||
|             elem.first.click(delay=randint(200, 500), timeout=3000) | ||||
|  | ||||
|     def action_click_element_containing_text_if_exists(self, selector=None, value=''): | ||||
|         logger.debug("Clicking element containing text if exists") | ||||
|         if not len(value.strip()): | ||||
|             return | ||||
|         elem = self.page.get_by_text(value) | ||||
|         logger.debug(f"Clicking element containing text - {elem.count()} elements found") | ||||
|         if elem.count(): | ||||
|             elem.first.click(delay=randint(200, 500), timeout=3000) | ||||
|         else: | ||||
|             return | ||||
|  | ||||
|     def action_enter_text_in_field(self, selector, value): | ||||
|         if not len(selector.strip()): | ||||
|             return | ||||
|   | ||||
| @@ -1,4 +1,7 @@ | ||||
| import importlib | ||||
| from concurrent.futures import ThreadPoolExecutor | ||||
|  | ||||
| from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
|  | ||||
| from functools import wraps | ||||
| @@ -30,7 +33,6 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     def long_task(uuid, preferred_proxy): | ||||
|         import time | ||||
|         from changedetectionio.content_fetchers import exceptions as content_fetcher_exceptions | ||||
|         from changedetectionio.processors.text_json_diff import text_json_diff | ||||
|         from changedetectionio.safe_jinja import render as jinja_render | ||||
|  | ||||
|         status = {'status': '', 'length': 0, 'text': ''} | ||||
| @@ -38,8 +40,12 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|         contents = '' | ||||
|         now = time.time() | ||||
|         try: | ||||
|             update_handler = text_json_diff.perform_site_check(datastore=datastore, watch_uuid=uuid) | ||||
|             update_handler.call_browser() | ||||
|             processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor") | ||||
|             update_handler = processor_module.perform_site_check(datastore=datastore, | ||||
|                                                                  watch_uuid=uuid | ||||
|                                                                  ) | ||||
|  | ||||
|             update_handler.call_browser(preferred_proxy_id=preferred_proxy) | ||||
|         # title, size is len contents not len xfer | ||||
|         except content_fetcher_exceptions.Non200ErrorCodeReceived as e: | ||||
|             if e.status_code == 404: | ||||
| @@ -48,7 +54,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|                 status.update({'status': 'ERROR', 'length': len(contents), 'text': f"{e.status_code} - Access denied"}) | ||||
|             else: | ||||
|                 status.update({'status': 'ERROR', 'length': len(contents), 'text': f"Status code: {e.status_code}"}) | ||||
|         except text_json_diff.FilterNotFoundInResponse: | ||||
|         except FilterNotFoundInResponse: | ||||
|             status.update({'status': 'OK', 'length': len(contents), 'text': f"OK but CSS/xPath filter not found (page changed layout?)"}) | ||||
|         except content_fetcher_exceptions.EmptyReply as e: | ||||
|             if e.status_code == 403 or e.status_code == 401: | ||||
|   | ||||
| @@ -19,7 +19,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue | ||||
|         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() | ||||
|         update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False})) | ||||
|         update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|         return redirect(url_for("index")) | ||||
|  | ||||
|     @login_required | ||||
|   | ||||
| @@ -17,7 +17,6 @@ | ||||
| </script> | ||||
|  | ||||
| <script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script> | ||||
| <!--<script src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script>--> | ||||
| <script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> | ||||
|  | ||||
| <div class="edit-form monospaced-textarea"> | ||||
| @@ -58,9 +57,9 @@ xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                         {% if '/text()' in  field %} | ||||
|                           <span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br> | ||||
|                         {% endif %} | ||||
|                         <span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br> | ||||
|  | ||||
|                     <ul> | ||||
|                         <span class="pure-form-message-inline">One CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br> | ||||
|                     <div data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div> | ||||
|                     <ul id="advanced-help-selectors"> | ||||
|                         <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li> | ||||
|                         <li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed). | ||||
|                             <ul> | ||||
| @@ -89,11 +88,13 @@ xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                     {{ render_field(form.subtractive_selectors, rows=5, placeholder="header | ||||
| footer | ||||
| nav | ||||
| .stockticker") }} | ||||
| .stockticker | ||||
| //*[contains(text(), 'Advertisement')]") }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                           <li> Remove HTML element(s) by CSS selector before text conversion. </li> | ||||
|                           <li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li> | ||||
|                           <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> | ||||
|   | ||||
| @@ -4,7 +4,9 @@ from loguru import logger | ||||
| from changedetectionio.content_fetchers.exceptions import BrowserStepsStepException | ||||
| import os | ||||
|  | ||||
| visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4,header,footer,section,article,aside,details,main,nav,section,summary' | ||||
| # Visual Selector scraper - 'Button' is there because some sites have <button>OUT OF STOCK</button>. | ||||
| visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4,header,footer,section,article,aside,details,main,nav,section,summary,button' | ||||
|  | ||||
|  | ||||
| # available_fetchers() will scan this implementation looking for anything starting with html_ | ||||
| # this information is used in the form selections | ||||
|   | ||||
| @@ -65,8 +65,8 @@ class Fetcher(): | ||||
|  | ||||
|     def __init__(self): | ||||
|         import importlib.resources | ||||
|         self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text() | ||||
|         self.instock_data_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text() | ||||
|         self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8') | ||||
|         self.instock_data_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8') | ||||
|  | ||||
|     @abstractmethod | ||||
|     def get_error(self): | ||||
| @@ -81,7 +81,8 @@ class Fetcher(): | ||||
|             request_method, | ||||
|             ignore_status_codes=False, | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|             is_binary=False, | ||||
|             empty_pages_are_a_change=False): | ||||
|         # Should set self.error, self.status_code and self.content | ||||
|         pass | ||||
|  | ||||
|   | ||||
| @@ -83,7 +83,8 @@ class fetcher(Fetcher): | ||||
|             request_method, | ||||
|             ignore_status_codes=False, | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|             is_binary=False, | ||||
|             empty_pages_are_a_change=False): | ||||
|  | ||||
|         from playwright.sync_api import sync_playwright | ||||
|         import playwright._impl._errors | ||||
| @@ -130,7 +131,7 @@ class fetcher(Fetcher): | ||||
|             if response is None: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 logger.debug("Content Fetcher > Response object was none") | ||||
|                 logger.debug("Content Fetcher > Response object from the browser communication was none") | ||||
|                 raise EmptyReply(url=url, status_code=None) | ||||
|  | ||||
|             try: | ||||
| @@ -166,10 +167,10 @@ class fetcher(Fetcher): | ||||
|  | ||||
|                 raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot) | ||||
|  | ||||
|             if len(self.page.content().strip()) == 0: | ||||
|             if not empty_pages_are_a_change and len(self.page.content().strip()) == 0: | ||||
|                 logger.debug("Content Fetcher > Content was empty, empty_pages_are_a_change = False") | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 logger.debug("Content Fetcher > Content was empty") | ||||
|                 raise EmptyReply(url=url, status_code=response.status) | ||||
|  | ||||
|             # Run Browser Steps here | ||||
|   | ||||
| @@ -75,7 +75,8 @@ class fetcher(Fetcher): | ||||
|                          request_method, | ||||
|                          ignore_status_codes, | ||||
|                          current_include_filters, | ||||
|                          is_binary | ||||
|                          is_binary, | ||||
|                          empty_pages_are_a_change | ||||
|                          ): | ||||
|  | ||||
|         from changedetectionio.content_fetchers import visualselector_xpath_selectors | ||||
| @@ -153,7 +154,7 @@ class fetcher(Fetcher): | ||||
|         if response is None: | ||||
|             await self.page.close() | ||||
|             await browser.close() | ||||
|             logger.warning("Content Fetcher > Response object was none") | ||||
|             logger.warning("Content Fetcher > Response object was none (as in, the response from the browser was empty, not just the content)") | ||||
|             raise EmptyReply(url=url, status_code=None) | ||||
|  | ||||
|         self.headers = response.headers | ||||
| @@ -186,10 +187,11 @@ class fetcher(Fetcher): | ||||
|  | ||||
|             raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot) | ||||
|         content = await self.page.content | ||||
|         if len(content.strip()) == 0: | ||||
|  | ||||
|         if not empty_pages_are_a_change and len(content.strip()) == 0: | ||||
|             logger.error("Content Fetcher > Content was empty (empty_pages_are_a_change is False), closing browsers") | ||||
|             await self.page.close() | ||||
|             await browser.close() | ||||
|             logger.error("Content Fetcher > Content was empty") | ||||
|             raise EmptyReply(url=url, status_code=response.status) | ||||
|  | ||||
|         # Run Browser Steps here | ||||
| @@ -247,7 +249,7 @@ class fetcher(Fetcher): | ||||
|         await self.fetch_page(**kwargs) | ||||
|  | ||||
|     def run(self, url, timeout, request_headers, request_body, request_method, ignore_status_codes=False, | ||||
|             current_include_filters=None, is_binary=False): | ||||
|             current_include_filters=None, is_binary=False, empty_pages_are_a_change=False): | ||||
|  | ||||
|         #@todo make update_worker async which could run any of these content_fetchers within memory and time constraints | ||||
|         max_time = os.getenv('PUPPETEER_MAX_PROCESSING_TIMEOUT_SECONDS', 180) | ||||
| @@ -262,7 +264,8 @@ class fetcher(Fetcher): | ||||
|                 request_method=request_method, | ||||
|                 ignore_status_codes=ignore_status_codes, | ||||
|                 current_include_filters=current_include_filters, | ||||
|                 is_binary=is_binary | ||||
|                 is_binary=is_binary, | ||||
|                 empty_pages_are_a_change=empty_pages_are_a_change | ||||
|             ), timeout=max_time)) | ||||
|         except asyncio.TimeoutError: | ||||
|             raise(BrowserFetchTimedOut(msg=f"Browser connected but was unable to process the page in {max_time} seconds.")) | ||||
|   | ||||
| @@ -1,9 +1,6 @@ | ||||
| from loguru import logger | ||||
| import hashlib | ||||
| import os | ||||
|  | ||||
| import chardet | ||||
| import requests | ||||
|  | ||||
| from changedetectionio import strtobool | ||||
| from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived | ||||
| from changedetectionio.content_fetchers.base import Fetcher | ||||
| @@ -26,7 +23,11 @@ class fetcher(Fetcher): | ||||
|             request_method, | ||||
|             ignore_status_codes=False, | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|             is_binary=False, | ||||
|             empty_pages_are_a_change=False): | ||||
|  | ||||
|         import chardet | ||||
|         import requests | ||||
|  | ||||
|         if self.browser_steps_get_valid_steps(): | ||||
|             raise BrowserStepsInUnsupportedFetcher(url=url) | ||||
| @@ -53,7 +54,7 @@ class fetcher(Fetcher): | ||||
|             session.mount('file://', FileAdapter()) | ||||
|  | ||||
|         r = session.request(method=request_method, | ||||
|                             data=request_body, | ||||
|                             data=request_body.encode('utf-8') if type(request_body) is str else request_body, | ||||
|                             url=url, | ||||
|                             headers=request_headers, | ||||
|                             timeout=timeout, | ||||
| @@ -74,7 +75,11 @@ class fetcher(Fetcher): | ||||
|         self.headers = r.headers | ||||
|  | ||||
|         if not r.content or not len(r.content): | ||||
|             raise EmptyReply(url=url, status_code=r.status_code) | ||||
|             logger.debug(f"Requests returned empty content for '{url}'") | ||||
|             if not empty_pages_are_a_change: | ||||
|                 raise EmptyReply(url=url, status_code=r.status_code) | ||||
|             else: | ||||
|                 logger.debug(f"URL {url} gave zero byte content reply with Status Code {r.status_code}, but empty_pages_are_a_change = True") | ||||
|  | ||||
|         # @todo test this | ||||
|         # @todo maybe you really want to test zero-byte return pages? | ||||
|   | ||||
| @@ -30,6 +30,8 @@ function isItemInStock() { | ||||
|         'dieser artikel ist bald wieder verfügbar', | ||||
|         'dostępne wkrótce', | ||||
|         'en rupture de stock', | ||||
|         'esgotado', | ||||
|         'indisponível', | ||||
|         'isn\'t in stock right now', | ||||
|         'isnt in stock right now', | ||||
|         'isn’t in stock right now', | ||||
| @@ -57,6 +59,7 @@ function isItemInStock() { | ||||
|         'notify me when available', | ||||
|         'notify me', | ||||
|         'notify when available', | ||||
|         'não disponível', | ||||
|         'não estamos a aceitar encomendas', | ||||
|         'out of stock', | ||||
|         'out-of-stock', | ||||
| @@ -75,6 +78,7 @@ function isItemInStock() { | ||||
|         'vergriffen', | ||||
|         'vorbestellen', | ||||
|         'vorbestellung ist bald möglich', | ||||
|         'we don\'t currently have any', | ||||
|         'we couldn\'t find any products that match', | ||||
|         'we do not currently have an estimate of when this product will be back in stock.', | ||||
|         'we don\'t know when or if this item will be back in stock.', | ||||
| @@ -153,10 +157,14 @@ function isItemInStock() { | ||||
|         } | ||||
|  | ||||
|         elementText = ""; | ||||
|         if (element.tagName.toLowerCase() === "input") { | ||||
|             elementText = element.value.toLowerCase().trim(); | ||||
|         } else { | ||||
|             elementText = getElementBaseText(element); | ||||
|         try { | ||||
|             if (element.tagName.toLowerCase() === "input") { | ||||
|                 elementText = element.value.toLowerCase().trim(); | ||||
|             } else { | ||||
|                 elementText = getElementBaseText(element); | ||||
|             } | ||||
|         } catch (e) { | ||||
|             console.warn('stock-not-in-stock.js scraper - handling element for gettext failed', e); | ||||
|         } | ||||
|  | ||||
|         if (elementText.length) { | ||||
| @@ -173,7 +181,8 @@ function isItemInStock() { | ||||
|         const element = elementsToScan[i]; | ||||
|         // outside the 'fold' or some weird text in the heading area | ||||
|         // .getBoundingClientRect() was causing a crash in chrome 119, can only be run on contentVisibility != hidden | ||||
|         if (element.getBoundingClientRect().top + window.scrollY >= vh + 150 || element.getBoundingClientRect().top + window.scrollY <= 100) { | ||||
|         // Note: theres also an automated test that places the 'out of stock' text fairly low down | ||||
|         if (element.getBoundingClientRect().top + window.scrollY >= vh + 250 || element.getBoundingClientRect().top + window.scrollY <= 100) { | ||||
|             continue | ||||
|         } | ||||
|         elementText = ""; | ||||
| @@ -187,7 +196,7 @@ function isItemInStock() { | ||||
|             // and these mean its out of stock | ||||
|             for (const outOfStockText of outOfStockTexts) { | ||||
|                 if (elementText.includes(outOfStockText)) { | ||||
|                     console.log(`Selected 'Out of Stock' - found text "${outOfStockText}" - "${elementText}"`) | ||||
|                     console.log(`Selected 'Out of Stock' - found text "${outOfStockText}" - "${elementText}" - offset top ${element.getBoundingClientRect().top}, page height is ${vh}`) | ||||
|                     return outOfStockText; // item is out of stock | ||||
|                 } | ||||
|             } | ||||
|   | ||||
| @@ -164,6 +164,15 @@ visibleElementsArray.forEach(function (element) { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     let label = "not-interesting" // A placeholder, the actual labels for training are done by hand for now | ||||
|  | ||||
|     let text = element.textContent.trim().slice(0, 30).trim(); | ||||
|     while (/\n{2,}|\t{2,}/.test(text)) { | ||||
|         text = text.replace(/\n{2,}/g, '\n').replace(/\t{2,}/g, '\t') | ||||
|     } | ||||
|  | ||||
|     // Try to identify any possible currency amounts "Sale: 4000" or "Sale now 3000 Kc", can help with the training. | ||||
|     const hasDigitCurrency = (/\d/.test(text.slice(0, 6)) || /\d/.test(text.slice(-6)) ) &&  /([€£$¥₩₹]|USD|AUD|EUR|Kč|kr|SEK|,–)/.test(text) ; | ||||
|  | ||||
|     size_pos.push({ | ||||
|         xpath: xpath_result, | ||||
| @@ -171,9 +180,16 @@ visibleElementsArray.forEach(function (element) { | ||||
|         height: Math.round(bbox['height']), | ||||
|         left: Math.floor(bbox['left']), | ||||
|         top: Math.floor(bbox['top']) + scroll_y, | ||||
|         // tagName used by Browser Steps | ||||
|         tagName: (element.tagName) ? element.tagName.toLowerCase() : '', | ||||
|         // tagtype used by Browser Steps | ||||
|         tagtype: (element.tagName.toLowerCase() === 'input' && element.type) ? element.type.toLowerCase() : '', | ||||
|         isClickable: window.getComputedStyle(element).cursor == "pointer" | ||||
|         isClickable: window.getComputedStyle(element).cursor === "pointer", | ||||
|         // Used by the keras trainer | ||||
|         fontSize: window.getComputedStyle(element).getPropertyValue('font-size'), | ||||
|         fontWeight: window.getComputedStyle(element).getPropertyValue('font-weight'), | ||||
|         hasDigitCurrency: hasDigitCurrency, | ||||
|         label: label, | ||||
|     }); | ||||
|  | ||||
| }); | ||||
|   | ||||
| @@ -56,7 +56,8 @@ class fetcher(Fetcher): | ||||
|             request_method, | ||||
|             ignore_status_codes=False, | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|             is_binary=False, | ||||
|             empty_pages_are_a_change=False): | ||||
|  | ||||
|         from selenium import webdriver | ||||
|         from selenium.webdriver.chrome.options import Options as ChromeOptions | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| import datetime | ||||
|  | ||||
| import flask_login | ||||
| import locale | ||||
| import os | ||||
| @@ -52,6 +53,7 @@ extra_stylesheets = [] | ||||
|  | ||||
| update_q = queue.PriorityQueue() | ||||
| notification_q = queue.Queue() | ||||
| MAX_QUEUE_SIZE = 2000 | ||||
|  | ||||
| app = Flask(__name__, | ||||
|             static_url_path="", | ||||
| @@ -66,7 +68,6 @@ FlaskCompress(app) | ||||
|  | ||||
| # Stop browser caching of assets | ||||
| app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 | ||||
|  | ||||
| app.config.exit = Event() | ||||
|  | ||||
| app.config['NEW_VERSION_AVAILABLE'] = False | ||||
| @@ -469,7 +470,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                     continue | ||||
|             if watch.get('last_error'): | ||||
|                 errored_count += 1 | ||||
|                  | ||||
|  | ||||
|             if search_q: | ||||
|                 if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower(): | ||||
|                     sorted_watches.append(watch) | ||||
| @@ -532,23 +533,32 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|     @login_optionally_required | ||||
|     def ajax_callback_send_notification_test(watch_uuid=None): | ||||
|  | ||||
|         # Watch_uuid could be unset in the case its used in tag editor, global setings | ||||
|         # Watch_uuid could be unset in the case it`s used in tag editor, global settings | ||||
|         import apprise | ||||
|         import random | ||||
|         from .apprise_asset import asset | ||||
|         apobj = apprise.Apprise(asset=asset) | ||||
|  | ||||
|         # so that the custom endpoints are registered | ||||
|         from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper | ||||
|         is_global_settings_form = request.args.get('mode', '') == 'global-settings' | ||||
|         is_group_settings_form = request.args.get('mode', '') == 'group-settings' | ||||
|  | ||||
|         # Use an existing random one on the global/main settings form | ||||
|         if not watch_uuid and (is_global_settings_form or is_group_settings_form): | ||||
|         if not watch_uuid and (is_global_settings_form or is_group_settings_form) \ | ||||
|                 and datastore.data.get('watching'): | ||||
|             logger.debug(f"Send test notification - Choosing random Watch {watch_uuid}") | ||||
|             watch_uuid = random.choice(list(datastore.data['watching'].keys())) | ||||
|  | ||||
|         if not watch_uuid: | ||||
|             return make_response("Error: You must have atleast one watch configured for 'test notification' to work", 400) | ||||
|  | ||||
|         watch = datastore.data['watching'].get(watch_uuid) | ||||
|  | ||||
|         notification_urls = request.form['notification_urls'].strip().splitlines() | ||||
|         notification_urls = None | ||||
|  | ||||
|         if request.form.get('notification_urls'): | ||||
|             notification_urls = request.form['notification_urls'].strip().splitlines() | ||||
|  | ||||
|         if not notification_urls: | ||||
|             logger.debug("Test notification - Trying by group/tag in the edit form if available") | ||||
| @@ -566,12 +576,12 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|  | ||||
|         if not notification_urls: | ||||
|             return 'No Notification URLs set/found' | ||||
|             return 'Error: No Notification URLs set/found' | ||||
|  | ||||
|         for n_url in notification_urls: | ||||
|             if len(n_url.strip()): | ||||
|                 if not apobj.add(n_url): | ||||
|                     return f'Error - {n_url} is not a valid AppRise URL.' | ||||
|                     return f'Error:  {n_url} is not a valid AppRise URL.' | ||||
|  | ||||
|         try: | ||||
|             # use the same as when it is triggered, but then override it with the form test values | ||||
| @@ -590,11 +600,13 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             if 'notification_body' in request.form and request.form['notification_body'].strip(): | ||||
|                 n_object['notification_body'] = request.form.get('notification_body', '').strip() | ||||
|  | ||||
|             n_object.update(watch.extra_notification_token_values()) | ||||
|  | ||||
|             from . import update_worker | ||||
|             new_worker = update_worker.update_worker(update_q, notification_q, app, datastore) | ||||
|             new_worker.queue_notification_for_watch(notification_q=notification_q, n_object=n_object, watch=watch) | ||||
|         except Exception as e: | ||||
|             return make_response({'error': str(e)}, 400) | ||||
|             return make_response(f"Error: str(e)", 400) | ||||
|  | ||||
|         return 'OK - Sent test notifications' | ||||
|  | ||||
| @@ -786,15 +798,15 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             # Recast it if need be to right data Watch handler | ||||
|             watch_class = get_custom_watch_obj_for_processor(form.data.get('processor')) | ||||
|             datastore.data['watching'][uuid] = watch_class(datastore_path=datastore_o.datastore_path, default=datastore.data['watching'][uuid]) | ||||
|  | ||||
|             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 | ||||
|  | ||||
|             # Queue the watch for immediate recheck, with a higher priority | ||||
|             update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False})) | ||||
|             if not datastore.data['watching'][uuid].get('paused'): | ||||
|                 # Queue the watch for immediate recheck, with a higher priority | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|             # Diff page [edit] link should go back to diff page | ||||
|             if request.args.get("next") and request.args.get("next") == 'diff': | ||||
| @@ -975,7 +987,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                 importer = import_url_list() | ||||
|                 importer.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff')) | ||||
|                 for uuid in importer.new_uuids: | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True})) | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|                 if len(importer.remaining_data) == 0: | ||||
|                     return redirect(url_for('index')) | ||||
| @@ -988,7 +1000,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                 d_importer = import_distill_io_json() | ||||
|                 d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore) | ||||
|                 for uuid in d_importer.new_uuids: | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True})) | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|             # XLSX importer | ||||
|             if request.files and request.files.get('xlsx_file'): | ||||
| @@ -1012,7 +1024,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                     w_importer.run(data=file, flash=flash, datastore=datastore) | ||||
|  | ||||
|                 for uuid in w_importer.new_uuids: | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True})) | ||||
|                     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) | ||||
| @@ -1153,8 +1165,6 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|     @login_optionally_required | ||||
|     def preview_page(uuid): | ||||
|         content = [] | ||||
|         ignored_line_numbers = [] | ||||
|         trigger_line_numbers = [] | ||||
|         versions = [] | ||||
|         timestamp = None | ||||
|  | ||||
| @@ -1171,11 +1181,10 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver' | ||||
|         extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')] | ||||
|  | ||||
|  | ||||
|         is_html_webdriver = False | ||||
|         if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'): | ||||
|             is_html_webdriver = True | ||||
|  | ||||
|         triggered_line_numbers = [] | ||||
|         if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()): | ||||
|             flash("Preview unavailable - No fetch/check completed or triggers not reached", "error") | ||||
|         else: | ||||
| @@ -1188,31 +1197,12 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|             try: | ||||
|                 versions = list(watch.history.keys()) | ||||
|                 tmp = watch.get_history_snapshot(timestamp).splitlines() | ||||
|                 content = watch.get_history_snapshot(timestamp) | ||||
|  | ||||
|                 # Get what needs to be highlighted | ||||
|                 ignore_rules = watch.get('ignore_text', []) + datastore.data['settings']['application']['global_ignore_text'] | ||||
|  | ||||
|                 # .readlines will keep the \n, but we will parse it here again, in the future tidy this up | ||||
|                 ignored_line_numbers = html_tools.strip_ignore_text(content="\n".join(tmp), | ||||
|                                                                     wordlist=ignore_rules, | ||||
|                                                                     mode='line numbers' | ||||
|                                                                     ) | ||||
|  | ||||
|                 trigger_line_numbers = html_tools.strip_ignore_text(content="\n".join(tmp), | ||||
|                                                                     wordlist=watch['trigger_text'], | ||||
|                                                                     mode='line numbers' | ||||
|                                                                     ) | ||||
|                 # Prepare the classes and lines used in the template | ||||
|                 i=0 | ||||
|                 for l in tmp: | ||||
|                     classes=[] | ||||
|                     i+=1 | ||||
|                     if i in ignored_line_numbers: | ||||
|                         classes.append('ignored') | ||||
|                     if i in trigger_line_numbers: | ||||
|                         classes.append('triggered') | ||||
|                     content.append({'line': l, 'classes': ' '.join(classes)}) | ||||
|                 triggered_line_numbers = html_tools.strip_ignore_text(content=content, | ||||
|                                                                       wordlist=watch['trigger_text'], | ||||
|                                                                       mode='line numbers' | ||||
|                                                                       ) | ||||
|  | ||||
|             except Exception as e: | ||||
|                 content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''}) | ||||
| @@ -1223,8 +1213,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                                  history_n=watch.history_n, | ||||
|                                  extra_stylesheets=extra_stylesheets, | ||||
|                                  extra_title=f" - Diff - {watch.label} @ {timestamp}", | ||||
|                                  ignored_line_numbers=ignored_line_numbers, | ||||
|                                  triggered_line_numbers=trigger_line_numbers, | ||||
|                                  triggered_line_numbers=triggered_line_numbers, | ||||
|                                  current_diff_url=watch['url'], | ||||
|                                  screenshot=watch.get_screenshot(), | ||||
|                                  watch=watch, | ||||
| @@ -1248,78 +1237,6 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     # We're good but backups are even better! | ||||
|     @app.route("/backup", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def get_backup(): | ||||
|  | ||||
|         import zipfile | ||||
|         from pathlib import Path | ||||
|  | ||||
|         # Remove any existing backup file, for now we just keep one file | ||||
|  | ||||
|         for previous_backup_filename in Path(datastore_o.datastore_path).rglob('changedetection-backup-*.zip'): | ||||
|             os.unlink(previous_backup_filename) | ||||
|  | ||||
|         # create a ZipFile object | ||||
|         timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") | ||||
|         backupname = "changedetection-backup-{}.zip".format(timestamp) | ||||
|         backup_filepath = os.path.join(datastore_o.datastore_path, backupname) | ||||
|  | ||||
|         with zipfile.ZipFile(backup_filepath, "w", | ||||
|                              compression=zipfile.ZIP_DEFLATED, | ||||
|                              compresslevel=8) as zipObj: | ||||
|  | ||||
|             # Be sure we're written fresh | ||||
|             datastore.sync_to_json() | ||||
|  | ||||
|             # Add the index | ||||
|             zipObj.write(os.path.join(datastore_o.datastore_path, "url-watches.json"), arcname="url-watches.json") | ||||
|  | ||||
|             # Add the flask app secret | ||||
|             zipObj.write(os.path.join(datastore_o.datastore_path, "secret.txt"), arcname="secret.txt") | ||||
|  | ||||
|             # Add any data in the watch data directory. | ||||
|             for uuid, w in datastore.data['watching'].items(): | ||||
|                 for f in Path(w.watch_data_dir).glob('*'): | ||||
|                     zipObj.write(f, | ||||
|                                  # Use the full path to access the file, but make the file 'relative' in the Zip. | ||||
|                                  arcname=os.path.join(f.parts[-2], f.parts[-1]), | ||||
|                                  compress_type=zipfile.ZIP_DEFLATED, | ||||
|                                  compresslevel=8) | ||||
|  | ||||
|             # Create a list file with just the URLs, so it's easier to port somewhere else in the future | ||||
|             list_file = "url-list.txt" | ||||
|             with open(os.path.join(datastore_o.datastore_path, list_file), "w") as f: | ||||
|                 for uuid in datastore.data["watching"]: | ||||
|                     url = datastore.data["watching"][uuid]["url"] | ||||
|                     f.write("{}\r\n".format(url)) | ||||
|             list_with_tags_file = "url-list-with-tags.txt" | ||||
|             with open( | ||||
|                 os.path.join(datastore_o.datastore_path, list_with_tags_file), "w" | ||||
|             ) as f: | ||||
|                 for uuid in datastore.data["watching"]: | ||||
|                     url = datastore.data["watching"][uuid].get('url') | ||||
|                     tag = datastore.data["watching"][uuid].get('tags', {}) | ||||
|                     f.write("{} {}\r\n".format(url, tag)) | ||||
|  | ||||
|             # Add it to the Zip | ||||
|             zipObj.write( | ||||
|                 os.path.join(datastore_o.datastore_path, list_file), | ||||
|                 arcname=list_file, | ||||
|                 compress_type=zipfile.ZIP_DEFLATED, | ||||
|                 compresslevel=8, | ||||
|             ) | ||||
|             zipObj.write( | ||||
|                 os.path.join(datastore_o.datastore_path, list_with_tags_file), | ||||
|                 arcname=list_with_tags_file, | ||||
|                 compress_type=zipfile.ZIP_DEFLATED, | ||||
|                 compresslevel=8, | ||||
|             ) | ||||
|  | ||||
|         # Send_from_directory needs to be the full absolute path | ||||
|         return send_from_directory(os.path.abspath(datastore_o.datastore_path), backupname, as_attachment=True) | ||||
|  | ||||
|     @app.route("/static/<string:group>/<string:filename>", methods=['GET']) | ||||
|     def static_content(group, filename): | ||||
|         from flask import make_response | ||||
| @@ -1377,22 +1294,33 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         import brotli | ||||
|  | ||||
|         watch = datastore.data['watching'].get(uuid) | ||||
|         if watch and os.path.isdir(watch.watch_data_dir): | ||||
|             latest_filename = list(watch.history.keys())[0] | ||||
|         if watch and watch.history.keys() and os.path.isdir(watch.watch_data_dir): | ||||
|             latest_filename = list(watch.history.keys())[-1] | ||||
|             html_fname = os.path.join(watch.watch_data_dir, f"{latest_filename}.html.br") | ||||
|             if html_fname.endswith('.br'): | ||||
|                 # Read and decompress the Brotli file | ||||
|                 with open(html_fname, 'rb') as f: | ||||
|             with open(html_fname, 'rb') as f: | ||||
|                 if html_fname.endswith('.br'): | ||||
|                     # Read and decompress the Brotli file | ||||
|                     decompressed_data = brotli.decompress(f.read()) | ||||
|                 else: | ||||
|                     decompressed_data = f.read() | ||||
|  | ||||
|                 buffer = BytesIO(decompressed_data) | ||||
|             buffer = BytesIO(decompressed_data) | ||||
|  | ||||
|                 return send_file(buffer, as_attachment=True, download_name=f"{latest_filename}.html", mimetype='text/html') | ||||
|             return send_file(buffer, as_attachment=True, download_name=f"{latest_filename}.html", mimetype='text/html') | ||||
|  | ||||
|  | ||||
|         # Return a 500 error | ||||
|         abort(500) | ||||
|  | ||||
|     # Ajax callback | ||||
|     @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def watch_get_preview_rendered(uuid): | ||||
|         '''For when viewing the "preview" of the rendered text from inside of Edit''' | ||||
|         from .processors.text_json_diff import prepare_filter_prevew | ||||
|         return prepare_filter_prevew(watch_uuid=uuid, datastore=datastore) | ||||
|  | ||||
|  | ||||
|     @app.route("/form/add/quickwatch", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def form_quick_watch_add(): | ||||
| @@ -1407,7 +1335,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         url = request.form.get('url').strip() | ||||
|         if datastore.url_exists(url): | ||||
|             flash(f'Warning, URL {url} already exists', "notice") | ||||
|              | ||||
|  | ||||
|         add_paused = request.form.get('edit_and_watch_submit_button') != None | ||||
|         processor = request.form.get('processor', 'text_json_diff') | ||||
|         new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor}) | ||||
| @@ -1453,7 +1381,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         new_uuid = datastore.clone(uuid) | ||||
|         if new_uuid: | ||||
|             if not datastore.data['watching'].get(uuid).get('paused'): | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid, 'skip_when_checksum_same': True})) | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid})) | ||||
|             flash('Cloned.') | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
| @@ -1474,7 +1402,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         if uuid: | ||||
|             if uuid not in running_uuids: | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False})) | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|             i = 1 | ||||
|  | ||||
|         elif tag: | ||||
| @@ -1485,7 +1413,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                         continue | ||||
|                     if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']: | ||||
|                         update_q.put( | ||||
|                             queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False}) | ||||
|                             queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}) | ||||
|                         ) | ||||
|                         i += 1 | ||||
|  | ||||
| @@ -1495,9 +1423,8 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                 if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']: | ||||
|                     if with_errors and not watch.get('last_error'): | ||||
|                         continue | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False})) | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})) | ||||
|                     i += 1 | ||||
|  | ||||
|         flash(f"{i} watches queued for rechecking.") | ||||
|         return redirect(url_for('index', tag=tag)) | ||||
|  | ||||
| @@ -1554,7 +1481,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     # Recheck and require a full reprocessing | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False})) | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|             flash("{} watches queued for rechecking".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'clear-errors'): | ||||
| @@ -1689,6 +1616,9 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|     import changedetectionio.blueprint.check_proxies as check_proxies | ||||
|     app.register_blueprint(check_proxies.construct_blueprint(datastore=datastore), url_prefix='/check_proxy') | ||||
|  | ||||
|     import changedetectionio.blueprint.backups as backups | ||||
|     app.register_blueprint(backups.construct_blueprint(datastore), url_prefix='/backups') | ||||
|  | ||||
|  | ||||
|     # @todo handle ctrl break | ||||
|     ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start() | ||||
| @@ -1813,12 +1743,14 @@ def ticker_thread_check_time_launch_checks(): | ||||
|             except RuntimeError as e: | ||||
|                 # RuntimeError: dictionary changed size during iteration | ||||
|                 time.sleep(0.1) | ||||
|                 watch_uuid_list = [] | ||||
|             else: | ||||
|                 break | ||||
|  | ||||
|         # Re #438 - Don't place more watches in the queue to be checked if the queue is already large | ||||
|         while update_q.qsize() >= 2000: | ||||
|             time.sleep(1) | ||||
|             logger.warning(f"Recheck watches queue size limit reached ({MAX_QUEUE_SIZE}), skipping adding more items") | ||||
|             time.sleep(3) | ||||
|  | ||||
|  | ||||
|         recheck_time_system_seconds = int(datastore.threshold_seconds) | ||||
| @@ -1878,7 +1810,7 @@ def ticker_thread_check_time_launch_checks(): | ||||
|                         f"{now - watch['last_checked']:0.2f}s since last checked") | ||||
|  | ||||
|                     # Into the queue with you | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=priority, item={'uuid': uuid, 'skip_when_checksum_same': True})) | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=priority, item={'uuid': uuid})) | ||||
|  | ||||
|                     # Reset for next time | ||||
|                     watch.jitter_seconds = 0 | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import os | ||||
| import re | ||||
| from loguru import logger | ||||
|  | ||||
| from changedetectionio.strtobool import strtobool | ||||
|  | ||||
| @@ -221,7 +222,8 @@ class ValidateAppRiseServers(object): | ||||
|     def __call__(self, form, field): | ||||
|         import apprise | ||||
|         apobj = apprise.Apprise() | ||||
|  | ||||
|         # so that the custom endpoints are registered | ||||
|         from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper | ||||
|         for server_url in field.data: | ||||
|             if not apobj.add(server_url): | ||||
|                 message = field.gettext('\'%s\' is not a valid AppRise URL.' % (server_url)) | ||||
| @@ -468,19 +470,21 @@ class processor_text_json_diff_form(commonSettingsForm): | ||||
|  | ||||
|     include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='') | ||||
|  | ||||
|     subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)]) | ||||
|     subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)]) | ||||
|  | ||||
|     extract_text = StringListField('Extract text', [ValidateListRegex()]) | ||||
|  | ||||
|     title = StringField('Title', default='') | ||||
|  | ||||
|     ignore_text = StringListField('Ignore text', [ValidateListRegex()]) | ||||
|     ignore_text = StringListField('Ignore lines containing', [ValidateListRegex()]) | ||||
|     headers = StringDictKeyValue('Request headers') | ||||
|     body = TextAreaField('Request body', [validators.Optional()]) | ||||
|     method = SelectField('Request method', choices=valid_method, default=default_method) | ||||
|     ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False) | ||||
|     check_unique_lines = BooleanField('Only trigger when unique lines appear', default=False) | ||||
|     check_unique_lines = BooleanField('Only trigger when unique lines appear in all history', default=False) | ||||
|     remove_duplicate_lines = BooleanField('Remove duplicate lines of text', default=False) | ||||
|     sort_text_alphabetically =  BooleanField('Sort text alphabetically', default=False) | ||||
|     trim_text_whitespace = BooleanField('Trim whitespace before and after text', default=False) | ||||
|  | ||||
|     filter_text_added = BooleanField('Added lines', default=True) | ||||
|     filter_text_replaced = BooleanField('Replaced/changed lines', default=True) | ||||
| @@ -492,7 +496,7 @@ class processor_text_json_diff_form(commonSettingsForm): | ||||
|     text_should_not_be_present = StringListField('Block change-detection while text matches', [validators.Optional(), ValidateListRegex()]) | ||||
|     webdriver_js_execute_code = TextAreaField('Execute JavaScript before change detection', render_kw={"rows": "5"}, validators=[validators.Optional()]) | ||||
|  | ||||
|     save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|     save_button = SubmitField('Save', render_kw={"class": "pure-button button-small pure-button-primary"}) | ||||
|  | ||||
|     proxy = RadioField('Proxy') | ||||
|     filter_failure_notification_send = BooleanField( | ||||
| @@ -511,6 +515,7 @@ class processor_text_json_diff_form(commonSettingsForm): | ||||
|         if not super().validate(): | ||||
|             return False | ||||
|  | ||||
|         from changedetectionio.safe_jinja import render as jinja_render | ||||
|         result = True | ||||
|  | ||||
|         # Fail form validation when a body is set for a GET | ||||
| @@ -520,11 +525,46 @@ class processor_text_json_diff_form(commonSettingsForm): | ||||
|  | ||||
|         # Attempt to validate jinja2 templates in the URL | ||||
|         try: | ||||
|             from changedetectionio.safe_jinja import render as jinja_render | ||||
|             jinja_render(template_str=self.url.data) | ||||
|         except Exception as e: | ||||
|             self.url.errors.append('Invalid template syntax') | ||||
|         except ModuleNotFoundError as e: | ||||
|             # incase jinja2_time or others is missing | ||||
|             logger.error(e) | ||||
|             self.url.errors.append(f'Invalid template syntax configuration: {e}') | ||||
|             result = False | ||||
|         except Exception as e: | ||||
|             logger.error(e) | ||||
|             self.url.errors.append(f'Invalid template syntax: {e}') | ||||
|             result = False | ||||
|  | ||||
|         # Attempt to validate jinja2 templates in the body | ||||
|         if self.body.data and self.body.data.strip(): | ||||
|             try: | ||||
|                 jinja_render(template_str=self.body.data) | ||||
|             except ModuleNotFoundError as e: | ||||
|                 # incase jinja2_time or others is missing | ||||
|                 logger.error(e) | ||||
|                 self.body.errors.append(f'Invalid template syntax configuration: {e}') | ||||
|                 result = False | ||||
|             except Exception as e: | ||||
|                 logger.error(e) | ||||
|                 self.body.errors.append(f'Invalid template syntax: {e}') | ||||
|                 result = False | ||||
|  | ||||
|         # Attempt to validate jinja2 templates in the headers | ||||
|         if len(self.headers.data) > 0: | ||||
|             try: | ||||
|                 for header, value in self.headers.data.items(): | ||||
|                     jinja_render(template_str=value) | ||||
|             except ModuleNotFoundError as e: | ||||
|                 # incase jinja2_time or others is missing | ||||
|                 logger.error(e) | ||||
|                 self.headers.errors.append(f'Invalid template syntax configuration: {e}') | ||||
|                 result = False | ||||
|             except Exception as e: | ||||
|                 logger.error(e) | ||||
|                 self.headers.errors.append(f'Invalid template syntax in "{header}" header: {e}') | ||||
|                 result = False | ||||
|  | ||||
|         return result | ||||
|  | ||||
| class SingleExtraProxy(Form): | ||||
| @@ -575,7 +615,7 @@ class globalSettingsApplicationForm(commonSettingsForm): | ||||
|     empty_pages_are_a_change =  BooleanField('Treat empty pages as a change?', default=False) | ||||
|     fetch_backend = RadioField('Fetch Method', default="html_requests", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) | ||||
|     global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()]) | ||||
|     global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)]) | ||||
|     global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)]) | ||||
|     ignore_whitespace = BooleanField('Ignore whitespace') | ||||
|     password = SaltyPasswordField() | ||||
|     pager_size = IntegerField('Pager size', | ||||
| @@ -605,7 +645,7 @@ class globalSettingsForm(Form): | ||||
|  | ||||
|     requests = FormField(globalSettingsRequestForm) | ||||
|     application = FormField(globalSettingsApplicationForm) | ||||
|     save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|     save_button = SubmitField('Save', render_kw={"class": "pure-button button-small pure-button-primary"}) | ||||
|  | ||||
|  | ||||
| class extractDataForm(Form): | ||||
|   | ||||
| @@ -1,18 +1,13 @@ | ||||
|  | ||||
| from bs4 import BeautifulSoup | ||||
| from inscriptis import get_text | ||||
| from jsonpath_ng.ext import parse | ||||
| from typing import List | ||||
| from inscriptis.model.config import ParserConfig | ||||
| from xml.sax.saxutils import escape as xml_escape | ||||
| from lxml import etree | ||||
| import json | ||||
| import re | ||||
|  | ||||
|  | ||||
| # HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis | ||||
| TEXT_FILTER_LIST_LINE_SUFFIX = "<br>" | ||||
|  | ||||
| TRANSLATE_WHITESPACE_TABLE = str.maketrans('', '', '\r\n\t ') | ||||
| PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$' | ||||
|  | ||||
| # 'price' , 'lowPrice', 'highPrice' are usually under here | ||||
| # All of those may or may not appear on different websites - I didnt find a way todo case-insensitive searching here | ||||
| LD_JSON_PRODUCT_OFFER_SELECTORS = ["json:$..offers", "json:$..Offers"] | ||||
| @@ -39,6 +34,7 @@ def perl_style_slash_enclosed_regex_to_options(regex): | ||||
|  | ||||
| # Given a CSS Rule, and a blob of HTML, return the blob of HTML that matches | ||||
| def include_filters(include_filters, html_content, append_pretty_line_formatting=False): | ||||
|     from bs4 import BeautifulSoup | ||||
|     soup = BeautifulSoup(html_content, "html.parser") | ||||
|     html_block = "" | ||||
|     r = soup.select(include_filters, separator="") | ||||
| @@ -56,16 +52,67 @@ def include_filters(include_filters, html_content, append_pretty_line_formatting | ||||
|     return html_block | ||||
|  | ||||
| def subtractive_css_selector(css_selector, html_content): | ||||
|     from bs4 import BeautifulSoup | ||||
|     soup = BeautifulSoup(html_content, "html.parser") | ||||
|     for item in soup.select(css_selector): | ||||
|  | ||||
|     # So that the elements dont shift their index, build a list of elements here which will be pointers to their place in the DOM | ||||
|     elements_to_remove = soup.select(css_selector) | ||||
|  | ||||
|     # Then, remove them in a separate loop | ||||
|     for item in elements_to_remove: | ||||
|         item.decompose() | ||||
|  | ||||
|     return str(soup) | ||||
|  | ||||
| def subtractive_xpath_selector(selectors: List[str], html_content: str) -> str: | ||||
|     # Parse the HTML content using lxml | ||||
|     html_tree = etree.HTML(html_content) | ||||
|  | ||||
|     # First, collect all elements to remove | ||||
|     elements_to_remove = [] | ||||
|  | ||||
|     # Iterate over the list of XPath selectors | ||||
|     for selector in selectors: | ||||
|         # Collect elements for each selector | ||||
|         elements_to_remove.extend(html_tree.xpath(selector)) | ||||
|  | ||||
|     # Then, remove them in a separate loop | ||||
|     for element in elements_to_remove: | ||||
|         if element.getparent() is not None:  # Ensure the element has a parent before removing | ||||
|             element.getparent().remove(element) | ||||
|  | ||||
|     # Convert the modified HTML tree back to a string | ||||
|     modified_html = etree.tostring(html_tree, method="html").decode("utf-8") | ||||
|     return modified_html | ||||
|  | ||||
|  | ||||
| def element_removal(selectors: List[str], html_content): | ||||
|     """Joins individual filters into one css filter.""" | ||||
|     selector = ",".join(selectors) | ||||
|     return subtractive_css_selector(selector, html_content) | ||||
|     """Removes elements that match a list of CSS or XPath selectors.""" | ||||
|     modified_html = html_content | ||||
|     css_selectors = [] | ||||
|     xpath_selectors = [] | ||||
|  | ||||
|     for selector in selectors: | ||||
|         if selector.startswith(('xpath:', 'xpath1:', '//')): | ||||
|             # Handle XPath selectors separately | ||||
|             xpath_selector = selector.removeprefix('xpath:').removeprefix('xpath1:') | ||||
|             xpath_selectors.append(xpath_selector) | ||||
|         else: | ||||
|             # Collect CSS selectors as one "hit", see comment in subtractive_css_selector | ||||
|             css_selectors.append(selector.strip().strip(",")) | ||||
|  | ||||
|     if xpath_selectors: | ||||
|         modified_html = subtractive_xpath_selector(xpath_selectors, modified_html) | ||||
|  | ||||
|     if css_selectors: | ||||
|         # Remove duplicates, then combine all CSS selectors into one string, separated by commas | ||||
|         # This stops the elements index shifting | ||||
|         unique_selectors = list(set(css_selectors))  # Ensure uniqueness | ||||
|         combined_css_selector = " , ".join(unique_selectors) | ||||
|         modified_html = subtractive_css_selector(combined_css_selector, modified_html) | ||||
|  | ||||
|  | ||||
|     return modified_html | ||||
|  | ||||
| def elementpath_tostring(obj): | ||||
|     """ | ||||
| @@ -181,6 +228,7 @@ def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=Fals | ||||
|  | ||||
| # Extract/find element | ||||
| def extract_element(find='title', html_content=''): | ||||
|     from bs4 import BeautifulSoup | ||||
|  | ||||
|     #Re #106, be sure to handle when its not found | ||||
|     element_text = None | ||||
| @@ -194,6 +242,8 @@ def extract_element(find='title', html_content=''): | ||||
|  | ||||
| # | ||||
| def _parse_json(json_data, json_filter): | ||||
|     from jsonpath_ng.ext import parse | ||||
|  | ||||
|     if json_filter.startswith("json:"): | ||||
|         jsonpath_expression = parse(json_filter.replace('json:', '')) | ||||
|         match = jsonpath_expression.find(json_data) | ||||
| @@ -242,6 +292,8 @@ def _get_stripped_text_from_json_match(match): | ||||
| # json_filter - ie json:$..price | ||||
| # ensure_is_ldjson_info_type - str "product", optional, "@type == product" (I dont know how to do that as a json selector) | ||||
| def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None): | ||||
|     from bs4 import BeautifulSoup | ||||
|  | ||||
|     stripped_text_from_html = False | ||||
| # https://github.com/dgtlmoon/changedetection.io/pull/2041#issuecomment-1848397161w | ||||
|     # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded within HTML tags | ||||
| @@ -309,6 +361,7 @@ def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None | ||||
| #          - "line numbers" return a list of line numbers that match (int list) | ||||
| # | ||||
| # wordlist - list of regex's (str) or words (str) | ||||
| # Preserves all linefeeds and other whitespacing, its not the job of this to remove that | ||||
| def strip_ignore_text(content, wordlist, mode="content"): | ||||
|     i = 0 | ||||
|     output = [] | ||||
| @@ -324,34 +377,33 @@ def strip_ignore_text(content, wordlist, mode="content"): | ||||
|         else: | ||||
|             ignore_text.append(k.strip()) | ||||
|  | ||||
|     for line in content.splitlines(): | ||||
|     for line in content.splitlines(keepends=True): | ||||
|         i += 1 | ||||
|         # Always ignore blank lines in this mode. (when this function gets called) | ||||
|         got_match = False | ||||
|         if len(line.strip()): | ||||
|             for l in ignore_text: | ||||
|                 if l.lower() in line.lower(): | ||||
|         for l in ignore_text: | ||||
|             if l.lower() in line.lower(): | ||||
|                 got_match = True | ||||
|  | ||||
|         if not got_match: | ||||
|             for r in ignore_regex: | ||||
|                 if r.search(line): | ||||
|                     got_match = True | ||||
|  | ||||
|             if not got_match: | ||||
|                 for r in ignore_regex: | ||||
|                     if r.search(line): | ||||
|                         got_match = True | ||||
|  | ||||
|             if not got_match: | ||||
|                 # Not ignored | ||||
|                 output.append(line.encode('utf8')) | ||||
|             else: | ||||
|                 ignored_line_numbers.append(i) | ||||
|  | ||||
|         if not got_match: | ||||
|             # Not ignored, and should preserve "keepends" | ||||
|             output.append(line) | ||||
|         else: | ||||
|             ignored_line_numbers.append(i) | ||||
|  | ||||
|     # Used for finding out what to highlight | ||||
|     if mode == "line numbers": | ||||
|         return ignored_line_numbers | ||||
|  | ||||
|     return "\n".encode('utf8').join(output) | ||||
|     return ''.join(output) | ||||
|  | ||||
| def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str: | ||||
|     from xml.sax.saxutils import escape as xml_escape | ||||
|     pattern = '<!\[CDATA\[(\s*(?:.(?<!\]\]>)\s*)*)\]\]>' | ||||
|     def repl(m): | ||||
|         text = m.group(1) | ||||
| @@ -360,6 +412,9 @@ def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False | ||||
|     return re.sub(pattern, repl, html_content) | ||||
|  | ||||
| def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False) -> str: | ||||
|     from inscriptis import get_text | ||||
|     from inscriptis.model.config import ParserConfig | ||||
|  | ||||
|     """Converts html string to a string with just the text. If ignoring | ||||
|     rendering anchor tag content is enable, anchor tag content are also | ||||
|     included in the text | ||||
|   | ||||
| @@ -6,6 +6,8 @@ import re | ||||
| from pathlib import Path | ||||
| from loguru import logger | ||||
|  | ||||
| from ..html_tools import TRANSLATE_WHITESPACE_TABLE | ||||
|  | ||||
| # Allowable protocols, protects against javascript: etc | ||||
| # file:// is further checked by ALLOW_FILE_URI | ||||
| SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):' | ||||
| @@ -36,8 +38,9 @@ class model(watch_base): | ||||
|     jitter_seconds = 0 | ||||
|  | ||||
|     def __init__(self, *arg, **kw): | ||||
|         self.__datastore_path = kw['datastore_path'] | ||||
|         del kw['datastore_path'] | ||||
|         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']) | ||||
| @@ -86,6 +89,10 @@ class model(watch_base): | ||||
|  | ||||
|         if ready_url.startswith('source:'): | ||||
|             ready_url=ready_url.replace('source:', '') | ||||
|  | ||||
|         # Also double check it after any Jinja2 formatting just incase | ||||
|         if not is_safe_url(ready_url): | ||||
|             return 'DISABLED' | ||||
|         return ready_url | ||||
|  | ||||
|     def clear_watch(self): | ||||
| @@ -171,6 +178,10 @@ class model(watch_base): | ||||
|         """ | ||||
|         tmp_history = {} | ||||
|  | ||||
|         # In the case we are only using the watch for processing without history | ||||
|         if not self.watch_data_dir: | ||||
|             return [] | ||||
|  | ||||
|         # Read the history file as a dict | ||||
|         fname = os.path.join(self.watch_data_dir, "history.txt") | ||||
|         if os.path.isfile(fname): | ||||
| @@ -307,13 +318,13 @@ class model(watch_base): | ||||
|             dest = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|             if not os.path.exists(dest): | ||||
|                 with open(dest, 'wb') as f: | ||||
|                     f.write(brotli.compress(contents, mode=brotli.MODE_TEXT)) | ||||
|                     f.write(brotli.compress(contents.encode('utf-8'), mode=brotli.MODE_TEXT)) | ||||
|         else: | ||||
|             snapshot_fname = f"{snapshot_id}.txt" | ||||
|             dest = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|             if not os.path.exists(dest): | ||||
|                 with open(dest, 'wb') as f: | ||||
|                     f.write(contents) | ||||
|                     f.write(contents.encode('utf-8')) | ||||
|  | ||||
|         # Append to index | ||||
|         # @todo check last char was \n | ||||
| @@ -345,14 +356,32 @@ class model(watch_base): | ||||
|         return seconds | ||||
|  | ||||
|     # Iterate over all history texts and see if something new exists | ||||
|     def lines_contain_something_unique_compared_to_history(self, lines: list): | ||||
|         local_lines = set([l.decode('utf-8').strip().lower() for l in lines]) | ||||
|     # Always applying .strip() to start/end but optionally replace any other whitespace | ||||
|     def lines_contain_something_unique_compared_to_history(self, lines: list, ignore_whitespace=False): | ||||
|         local_lines = [] | ||||
|         if lines: | ||||
|             if ignore_whitespace: | ||||
|                 if isinstance(lines[0], str): # Can be either str or bytes depending on what was on the disk | ||||
|                     local_lines = set([l.translate(TRANSLATE_WHITESPACE_TABLE).lower() for l in lines]) | ||||
|                 else: | ||||
|                     local_lines = set([l.decode('utf-8').translate(TRANSLATE_WHITESPACE_TABLE).lower() for l in lines]) | ||||
|             else: | ||||
|                 if isinstance(lines[0], str): # Can be either str or bytes depending on what was on the disk | ||||
|                     local_lines = set([l.strip().lower() for l in lines]) | ||||
|                 else: | ||||
|                     local_lines = set([l.decode('utf-8').strip().lower() for l in lines]) | ||||
|  | ||||
|  | ||||
|         # Compare each lines (set) against each history text file (set) looking for something new.. | ||||
|         existing_history = set({}) | ||||
|         for k, v in self.history.items(): | ||||
|             content = self.get_history_snapshot(k) | ||||
|             alist = set([line.strip().lower() for line in content.splitlines()]) | ||||
|  | ||||
|             if ignore_whitespace: | ||||
|                 alist = set([line.translate(TRANSLATE_WHITESPACE_TABLE).lower() for line in content.splitlines()]) | ||||
|             else: | ||||
|                 alist = set([line.strip().lower() for line in content.splitlines()]) | ||||
|  | ||||
|             existing_history = existing_history.union(alist) | ||||
|  | ||||
|         # Check that everything in local_lines(new stuff) already exists in existing_history - it should | ||||
| @@ -396,8 +425,8 @@ class model(watch_base): | ||||
|     @property | ||||
|     def watch_data_dir(self): | ||||
|         # The base dir of the watch data | ||||
|         return os.path.join(self.__datastore_path, self['uuid']) | ||||
|      | ||||
|         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") | ||||
|   | ||||
| @@ -18,6 +18,7 @@ class watch_base(dict): | ||||
|             '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, | ||||
| @@ -60,6 +61,8 @@ class watch_base(dict): | ||||
|             'time_between_check_use_default': True, | ||||
|             '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()), | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| import apprise | ||||
|  | ||||
| import time | ||||
| from apprise import NotifyFormat | ||||
| import json | ||||
| import apprise | ||||
| from loguru import logger | ||||
|  | ||||
|  | ||||
| valid_tokens = { | ||||
|     'base_url': '', | ||||
|     'current_snapshot': '', | ||||
| @@ -34,86 +35,11 @@ valid_notification_formats = { | ||||
|     default_notification_format_for_watch: default_notification_format_for_watch | ||||
| } | ||||
|  | ||||
| # include the decorator | ||||
| from apprise.decorators import notify | ||||
|  | ||||
| @notify(on="delete") | ||||
| @notify(on="deletes") | ||||
| @notify(on="get") | ||||
| @notify(on="gets") | ||||
| @notify(on="post") | ||||
| @notify(on="posts") | ||||
| @notify(on="put") | ||||
| @notify(on="puts") | ||||
| def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs): | ||||
|     import requests | ||||
|     from apprise.utils import parse_url as apprise_parse_url | ||||
|     from apprise import URLBase | ||||
|  | ||||
|     url = kwargs['meta'].get('url') | ||||
|  | ||||
|     if url.startswith('post'): | ||||
|         r = requests.post | ||||
|     elif url.startswith('get'): | ||||
|         r = requests.get | ||||
|     elif url.startswith('put'): | ||||
|         r = requests.put | ||||
|     elif url.startswith('delete'): | ||||
|         r = requests.delete | ||||
|  | ||||
|     url = url.replace('post://', 'http://') | ||||
|     url = url.replace('posts://', 'https://') | ||||
|     url = url.replace('put://', 'http://') | ||||
|     url = url.replace('puts://', 'https://') | ||||
|     url = url.replace('get://', 'http://') | ||||
|     url = url.replace('gets://', 'https://') | ||||
|     url = url.replace('put://', 'http://') | ||||
|     url = url.replace('puts://', 'https://') | ||||
|     url = url.replace('delete://', 'http://') | ||||
|     url = url.replace('deletes://', 'https://') | ||||
|  | ||||
|     headers = {} | ||||
|     params = {} | ||||
|     auth = None | ||||
|  | ||||
|     # Convert /foobar?+some-header=hello to proper header dictionary | ||||
|     results = apprise_parse_url(url) | ||||
|     if results: | ||||
|         # Add our headers that the user can potentially over-ride if they wish | ||||
|         # to to our returned result set and tidy entries by unquoting them | ||||
|         headers = {URLBase.unquote(x): URLBase.unquote(y) | ||||
|                    for x, y in results['qsd+'].items()} | ||||
|  | ||||
|         # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation | ||||
|         # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise | ||||
|         # but here we are making straight requests, so we need todo convert this against apprise's logic | ||||
|         for k, v in results['qsd'].items(): | ||||
|             if not k.strip('+-') in results['qsd+'].keys(): | ||||
|                 params[URLBase.unquote(k)] = URLBase.unquote(v) | ||||
|  | ||||
|         # Determine Authentication | ||||
|         auth = '' | ||||
|         if results.get('user') and results.get('password'): | ||||
|             auth = (URLBase.unquote(results.get('user')), URLBase.unquote(results.get('user'))) | ||||
|         elif results.get('user'): | ||||
|             auth = (URLBase.unquote(results.get('user'))) | ||||
|  | ||||
|     # Try to auto-guess if it's JSON | ||||
|     try: | ||||
|         json.loads(body) | ||||
|         headers['Content-Type'] = 'application/json; charset=utf-8' | ||||
|     except ValueError as e: | ||||
|         pass | ||||
|  | ||||
|     r(results.get('url'), | ||||
|       auth=auth, | ||||
|       data=body, | ||||
|       headers=headers, | ||||
|       params=params | ||||
|       ) | ||||
|  | ||||
|  | ||||
| def process_notification(n_object, datastore): | ||||
|     # so that the custom endpoints are registered | ||||
|     from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper | ||||
|  | ||||
|     from .safe_jinja import render as jinja_render | ||||
|     now = time.time() | ||||
|   | ||||
| @@ -1,14 +1,14 @@ | ||||
| from abc import abstractmethod | ||||
| from changedetectionio.content_fetchers.base import Fetcher | ||||
| from changedetectionio.strtobool import strtobool | ||||
|  | ||||
| from copy import deepcopy | ||||
| from loguru import logger | ||||
| import hashlib | ||||
| import os | ||||
| import re | ||||
| import importlib | ||||
| import pkgutil | ||||
| import inspect | ||||
| import os | ||||
| import pkgutil | ||||
| import re | ||||
|  | ||||
| class difference_detection_processor(): | ||||
|  | ||||
| @@ -18,28 +18,33 @@ class difference_detection_processor(): | ||||
|     screenshot = None | ||||
|     watch = None | ||||
|     xpath_data = None | ||||
|     preferred_proxy = None | ||||
|  | ||||
|     def __init__(self, *args, datastore, watch_uuid, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.datastore = datastore | ||||
|         self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid)) | ||||
|         # Generic fetcher that should be extended (requests, playwright etc) | ||||
|         self.fetcher = Fetcher() | ||||
|  | ||||
|     def call_browser(self, preferred_proxy_id=None): | ||||
|  | ||||
|     def call_browser(self): | ||||
|         from requests.structures import CaseInsensitiveDict | ||||
|         # Protect against file:// access | ||||
|         if re.search(r'^file://', self.watch.get('url', '').strip(), re.IGNORECASE): | ||||
|  | ||||
|         url = self.watch.link | ||||
|  | ||||
|         # Protect against file://, file:/ access, check the real "link" without any meta "source:" etc prepended. | ||||
|         if re.search(r'^file:/', url.strip(), re.IGNORECASE): | ||||
|             if not strtobool(os.getenv('ALLOW_FILE_URI', 'false')): | ||||
|                 raise Exception( | ||||
|                     "file:// type access is denied for security reasons." | ||||
|                 ) | ||||
|  | ||||
|         url = self.watch.link | ||||
|  | ||||
|         # Requests, playwright, other browser via wss:// etc, fetch_extra_something | ||||
|         prefer_fetch_backend = self.watch.get('fetch_backend', 'system') | ||||
|  | ||||
|         # Proxy ID "key" | ||||
|         preferred_proxy_id = self.datastore.get_preferred_proxy_for_watch(uuid=self.watch.get('uuid')) | ||||
|         preferred_proxy_id = preferred_proxy_id if preferred_proxy_id else self.datastore.get_preferred_proxy_for_watch(uuid=self.watch.get('uuid')) | ||||
|  | ||||
|         # Pluggable content self.fetcher | ||||
|         if not prefer_fetch_backend or prefer_fetch_backend == 'system': | ||||
| @@ -97,6 +102,7 @@ class difference_detection_processor(): | ||||
|             self.fetcher.browser_steps_screenshot_path = os.path.join(self.datastore.datastore_path, self.watch.get('uuid')) | ||||
|  | ||||
|         # Tweak the base config with the per-watch ones | ||||
|         from changedetectionio.safe_jinja import render as jinja_render | ||||
|         request_headers = CaseInsensitiveDict() | ||||
|  | ||||
|         ua = self.datastore.data['settings']['requests'].get('default_ua') | ||||
| @@ -113,9 +119,15 @@ class difference_detection_processor(): | ||||
|         if 'Accept-Encoding' in request_headers and "br" in request_headers['Accept-Encoding']: | ||||
|             request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '') | ||||
|  | ||||
|         for header_name in request_headers: | ||||
|             request_headers.update({header_name: jinja_render(template_str=request_headers.get(header_name))}) | ||||
|  | ||||
|         timeout = self.datastore.data['settings']['requests'].get('timeout') | ||||
|  | ||||
|         request_body = self.watch.get('body') | ||||
|         if request_body: | ||||
|             request_body = jinja_render(template_str=self.watch.get('body')) | ||||
|          | ||||
|         request_method = self.watch.get('method') | ||||
|         ignore_status_codes = self.watch.get('ignore_status_codes', False) | ||||
|  | ||||
| @@ -133,8 +145,18 @@ class difference_detection_processor(): | ||||
|         is_binary = self.watch.is_pdf | ||||
|  | ||||
|         # And here we go! call the right browser with browser-specific settings | ||||
|         self.fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_codes, self.watch.get('include_filters'), | ||||
|                     is_binary=is_binary) | ||||
|         empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False) | ||||
|  | ||||
|         self.fetcher.run(url=url, | ||||
|                          timeout=timeout, | ||||
|                          request_headers=request_headers, | ||||
|                          request_body=request_body, | ||||
|                          request_method=request_method, | ||||
|                          ignore_status_codes=ignore_status_codes, | ||||
|                          current_include_filters=self.watch.get('include_filters'), | ||||
|                          is_binary=is_binary, | ||||
|                          empty_pages_are_a_change=empty_pages_are_a_change | ||||
|                          ) | ||||
|  | ||||
|         #@todo .quit here could go on close object, so we can run JS if change-detected | ||||
|         self.fetcher.quit() | ||||
| @@ -142,7 +164,7 @@ class difference_detection_processor(): | ||||
|         # After init, call run_changedetection() which will do the actual change-detection | ||||
|  | ||||
|     @abstractmethod | ||||
|     def run_changedetection(self, watch, skip_when_checksum_same=True): | ||||
|     def run_changedetection(self, watch): | ||||
|         update_obj = {'last_notification_error': False, 'last_error': False} | ||||
|         some_data = 'xxxxx' | ||||
|         update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest() | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
|  | ||||
| from changedetectionio.model.Watch import model as BaseWatch | ||||
| import re | ||||
| from babel.numbers import parse_decimal | ||||
| from changedetectionio.model.Watch import model as BaseWatch | ||||
| from typing import Union | ||||
| import re | ||||
|  | ||||
| class Restock(dict): | ||||
|  | ||||
|     def parse_currency(self, raw_value: str) -> float: | ||||
|     def parse_currency(self, raw_value: str) -> Union[float, None]: | ||||
|         # 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 | ||||
|  | ||||
| @@ -21,8 +22,11 @@ class Restock(dict): | ||||
|         # Remove any non-numeric characters except for the decimal point | ||||
|         standardized_value = re.sub(r'[^\d.-]', '', standardized_value) | ||||
|  | ||||
|         # Convert to float | ||||
|         return float(parse_decimal(standardized_value, locale='en')) | ||||
|         if standardized_value: | ||||
|             # Convert to float | ||||
|             return float(parse_decimal(standardized_value, locale='en')) | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         # Define default values | ||||
|   | ||||
| @@ -2,8 +2,7 @@ from .. import difference_detection_processor | ||||
| from ..exceptions import ProcessorException | ||||
| from . import Restock | ||||
| from loguru import logger | ||||
| import hashlib | ||||
| import re | ||||
|  | ||||
| import urllib3 | ||||
| import time | ||||
|  | ||||
| @@ -27,6 +26,30 @@ def _search_prop_by_value(matches, value): | ||||
|             if value in prop[0]: | ||||
|                 return prop[1]  # Yield the desired value and exit the function | ||||
|  | ||||
| def _deduplicate_prices(data): | ||||
|     import re | ||||
|  | ||||
|     ''' | ||||
|     Some price data has multiple entries, OR it has a single entry with ['$159', '159', 159, "$ 159"] or just "159" | ||||
|     Get all the values, clean it and add it to a set then return the unique values | ||||
|     ''' | ||||
|     unique_data = set() | ||||
|  | ||||
|     # Return the complete 'datum' where its price was not seen before | ||||
|     for datum in data: | ||||
|  | ||||
|         if isinstance(datum.value, list): | ||||
|             # Process each item in the list | ||||
|             normalized_value = set([float(re.sub(r'[^\d.]', '', str(item))) for item in datum.value if str(item).strip()]) | ||||
|             unique_data.update(normalized_value) | ||||
|         else: | ||||
|             # Process single value | ||||
|             v = float(re.sub(r'[^\d.]', '', str(datum.value))) | ||||
|             unique_data.add(v) | ||||
|  | ||||
|     return list(unique_data) | ||||
|  | ||||
|  | ||||
| # should return Restock() | ||||
| # add casting? | ||||
| def get_itemprop_availability(html_content) -> Restock: | ||||
| @@ -36,17 +59,21 @@ def get_itemprop_availability(html_content) -> Restock: | ||||
|     """ | ||||
|     from jsonpath_ng import parse | ||||
|  | ||||
|     import re | ||||
|     now = time.time() | ||||
|     import extruct | ||||
|     logger.trace(f"Imported extruct module in {time.time() - now:.3f}s") | ||||
|  | ||||
|     value = {} | ||||
|     now = time.time() | ||||
|  | ||||
|     # Extruct is very slow, I'm wondering if some ML is going to be faster (800ms on my i7), 'rdfa' seems to be the heaviest. | ||||
|  | ||||
|     syntaxes = ['dublincore', 'json-ld', 'microdata', 'microformat', 'opengraph'] | ||||
|     try: | ||||
|         data = extruct.extract(html_content, syntaxes=syntaxes) | ||||
|     except Exception as e: | ||||
|         logger.warning(f"Unable to extract data, document parsing with extruct failed with {type(e).__name__} - {str(e)}") | ||||
|         return Restock() | ||||
|  | ||||
|     data = extruct.extract(html_content, syntaxes=syntaxes) | ||||
|     logger.trace(f"Extruct basic extract of all metadata done in {time.time() - now:.3f}s") | ||||
|  | ||||
|     # First phase, dead simple scanning of anything that looks useful | ||||
| @@ -57,18 +84,17 @@ def get_itemprop_availability(html_content) -> Restock: | ||||
|         pricecurrency_parse = parse('$..(pricecurrency|currency|priceCurrency )') | ||||
|         availability_parse = parse('$..(availability|Availability)') | ||||
|  | ||||
|         price_result = price_parse.find(data) | ||||
|         price_result = _deduplicate_prices(price_parse.find(data)) | ||||
|         if price_result: | ||||
|             # Right now, we just support single product items, maybe we will store the whole actual metadata seperately in teh future and | ||||
|             # parse that for the UI? | ||||
|             prices_found = set(str(item.value).replace('$', '') for item in price_result) | ||||
|             if len(price_result) > 1 and len(prices_found) > 1: | ||||
|             if len(price_result) > 1 and len(price_result) > 1: | ||||
|                 # See of all prices are different, in the case that one product has many embedded data types with the same price | ||||
|                 # One might have $121.95 and another 121.95 etc | ||||
|                 logger.warning(f"More than one price found {prices_found}, throwing exception, cant use this plugin.") | ||||
|                 logger.warning(f"More than one price found {price_result}, throwing exception, cant use this plugin.") | ||||
|                 raise MoreThanOnePriceFound() | ||||
|  | ||||
|             value['price'] = price_result[0].value | ||||
|             value['price'] = price_result[0] | ||||
|  | ||||
|         pricecurrency_result = pricecurrency_parse.find(data) | ||||
|         if pricecurrency_result: | ||||
| @@ -118,7 +144,9 @@ class perform_site_check(difference_detection_processor): | ||||
|     screenshot = None | ||||
|     xpath_data = None | ||||
|  | ||||
|     def run_changedetection(self, watch, skip_when_checksum_same=True): | ||||
|     def run_changedetection(self, watch): | ||||
|         import hashlib | ||||
|  | ||||
|         if not watch: | ||||
|             raise Exception("Watch no longer exists.") | ||||
|  | ||||
| @@ -132,6 +160,20 @@ class perform_site_check(difference_detection_processor): | ||||
|         update_obj['content_type'] = self.fetcher.headers.get('Content-Type', '') | ||||
|         update_obj["last_check_status"] = self.fetcher.get_last_status_code() | ||||
|  | ||||
|         # Only try to process restock information (like scraping for keywords) if the page was actually rendered correctly. | ||||
|         # Otherwise it will assume "in stock" because nothing suggesting the opposite was found | ||||
|         from ...html_tools import html_to_text | ||||
|         text = html_to_text(self.fetcher.content) | ||||
|         logger.debug(f"Length of text after conversion: {len(text)}") | ||||
|         if not len(text): | ||||
|             from ...content_fetchers.exceptions import ReplyWithContentButNoText | ||||
|             raise ReplyWithContentButNoText(url=watch.link, | ||||
|                                             status_code=self.fetcher.get_last_status_code(), | ||||
|                                             screenshot=self.fetcher.screenshot, | ||||
|                                             html_content=self.fetcher.content, | ||||
|                                             xpath_data=self.fetcher.xpath_data | ||||
|                                             ) | ||||
|  | ||||
|         # Which restock settings to compare against? | ||||
|         restock_settings = watch.get('restock_settings', {}) | ||||
|  | ||||
| @@ -146,7 +188,7 @@ class perform_site_check(difference_detection_processor): | ||||
|  | ||||
|         itemprop_availability = {} | ||||
|         try: | ||||
|             itemprop_availability = get_itemprop_availability(html_content=self.fetcher.content) | ||||
|             itemprop_availability = get_itemprop_availability(self.fetcher.content) | ||||
|         except MoreThanOnePriceFound as e: | ||||
|             # Add the real data | ||||
|             raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.", | ||||
| @@ -182,7 +224,7 @@ class perform_site_check(difference_detection_processor): | ||||
|             itemprop_availability['original_price'] = itemprop_availability.get('price') | ||||
|             update_obj['restock']["original_price"] = itemprop_availability.get('price') | ||||
|  | ||||
|         if not self.fetcher.instock_data and not itemprop_availability.get('availability'): | ||||
|         if not self.fetcher.instock_data and not itemprop_availability.get('availability') and not itemprop_availability.get('price'): | ||||
|             raise ProcessorException( | ||||
|                 message=f"Unable to extract restock data for this page unfortunately. (Got code {self.fetcher.get_last_status_code()} from server), no embedded stock information was found and nothing interesting in the text, try using this watch with Chrome.", | ||||
|                 url=watch.get('url'), | ||||
| @@ -191,12 +233,21 @@ class perform_site_check(difference_detection_processor): | ||||
|                 xpath_data=self.fetcher.xpath_data | ||||
|                 ) | ||||
|  | ||||
|         logger.debug(f"self.fetcher.instock_data is - '{self.fetcher.instock_data}' and itemprop_availability.get('availability') is {itemprop_availability.get('availability')}") | ||||
|         # Nothing automatic in microdata found, revert to scraping the page | ||||
|         if self.fetcher.instock_data and itemprop_availability.get('availability') is None: | ||||
|             # 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold. | ||||
|             # Careful! this does not really come from chrome/js when the watch is set to plaintext | ||||
|             update_obj['restock']["in_stock"] = True if self.fetcher.instock_data == 'Possibly in stock' else False | ||||
|             logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned '{self.fetcher.instock_data}' from JS scraper.") | ||||
|             logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned instock_data - '{self.fetcher.instock_data}' from JS scraper.") | ||||
|  | ||||
|         # Very often websites will lie about the 'availability' in the metadata, so if the scraped version says its NOT in stock, use that. | ||||
|         if self.fetcher.instock_data and self.fetcher.instock_data != 'Possibly in stock': | ||||
|             if update_obj['restock'].get('in_stock'): | ||||
|                 logger.warning( | ||||
|                     f"Lie detected in the availability machine data!! when scraping said its not in stock!! itemprop was '{itemprop_availability}' and scraped from browser was '{self.fetcher.instock_data}' update obj was {update_obj['restock']} ") | ||||
|                 logger.warning(f"Setting instock to FALSE, scraper found '{self.fetcher.instock_data}' in the body but metadata reported not-in-stock") | ||||
|                 update_obj['restock']["in_stock"] = False | ||||
|  | ||||
|         # What we store in the snapshot | ||||
|         price = update_obj.get('restock').get('price') if update_obj.get('restock').get('price') else "" | ||||
| @@ -260,4 +311,4 @@ class perform_site_check(difference_detection_processor): | ||||
|         # Always record the new checksum | ||||
|         update_obj["previous_md5"] = fetched_md5 | ||||
|  | ||||
|         return changed_detected, update_obj, snapshot_content.encode('utf-8').strip() | ||||
|         return changed_detected, update_obj, snapshot_content.strip() | ||||
|   | ||||
| @@ -0,0 +1,115 @@ | ||||
|  | ||||
| from loguru import logger | ||||
|  | ||||
|  | ||||
|  | ||||
| def _task(watch, update_handler): | ||||
|     from changedetectionio.content_fetchers.exceptions import ReplyWithContentButNoText | ||||
|     from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse | ||||
|  | ||||
|     text_after_filter = '' | ||||
|  | ||||
|     try: | ||||
|         # The slow process (we run 2 of these in parallel) | ||||
|         changed_detected, update_obj, text_after_filter = update_handler.run_changedetection(watch=watch) | ||||
|     except FilterNotFoundInResponse as e: | ||||
|         text_after_filter = f"Filter not found in HTML: {str(e)}" | ||||
|     except ReplyWithContentButNoText as e: | ||||
|         text_after_filter = f"Filter found but no text (empty result)" | ||||
|     except Exception as e: | ||||
|         text_after_filter = f"Error: {str(e)}" | ||||
|  | ||||
|     if not text_after_filter.strip(): | ||||
|         text_after_filter = 'Empty content' | ||||
|  | ||||
|     # because run_changedetection always returns bytes due to saving the snapshots etc | ||||
|     text_after_filter = text_after_filter.decode('utf-8') if isinstance(text_after_filter, bytes) else text_after_filter | ||||
|  | ||||
|     return text_after_filter | ||||
|  | ||||
|  | ||||
| def prepare_filter_prevew(datastore, watch_uuid): | ||||
|     '''Used by @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])''' | ||||
|     from changedetectionio import forms, html_tools | ||||
|     from changedetectionio.model.Watch import model as watch_model | ||||
|     from concurrent.futures import ProcessPoolExecutor | ||||
|     from copy import deepcopy | ||||
|     from flask import request, jsonify | ||||
|     import brotli | ||||
|     import importlib | ||||
|     import os | ||||
|     import time | ||||
|     now = time.time() | ||||
|  | ||||
|     text_after_filter = '' | ||||
|     text_before_filter = '' | ||||
|     trigger_line_numbers = [] | ||||
|     ignore_line_numbers = [] | ||||
|  | ||||
|     tmp_watch = deepcopy(datastore.data['watching'].get(watch_uuid)) | ||||
|  | ||||
|     if tmp_watch and tmp_watch.history and os.path.isdir(tmp_watch.watch_data_dir): | ||||
|         # Splice in the temporary stuff from the form | ||||
|         form = forms.processor_text_json_diff_form(formdata=request.form if request.method == 'POST' else None, | ||||
|                                                    data=request.form | ||||
|                                                    ) | ||||
|  | ||||
|         # Only update vars that came in via the AJAX post | ||||
|         p = {k: v for k, v in form.data.items() if k in request.form.keys()} | ||||
|         tmp_watch.update(p) | ||||
|         blank_watch_no_filters = watch_model() | ||||
|         blank_watch_no_filters['url'] = tmp_watch.get('url') | ||||
|  | ||||
|         latest_filename = next(reversed(tmp_watch.history)) | ||||
|         html_fname = os.path.join(tmp_watch.watch_data_dir, f"{latest_filename}.html.br") | ||||
|         with open(html_fname, 'rb') as f: | ||||
|             decompressed_data = brotli.decompress(f.read()).decode('utf-8') if html_fname.endswith('.br') else f.read().decode('utf-8') | ||||
|  | ||||
|             # Just like a normal change detection except provide a fake "watch" object and dont call .call_browser() | ||||
|             processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor") | ||||
|             update_handler = processor_module.perform_site_check(datastore=datastore, | ||||
|                                                                  watch_uuid=tmp_watch.get('uuid')  # probably not needed anymore anyway? | ||||
|                                                                  ) | ||||
|             # Use the last loaded HTML as the input | ||||
|             update_handler.datastore = datastore | ||||
|             update_handler.fetcher.content = str(decompressed_data) # str() because playwright/puppeteer/requests return string | ||||
|             update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type') | ||||
|  | ||||
|             # Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk | ||||
|             # Do this as a parallel process because it could take some time | ||||
|             with ProcessPoolExecutor(max_workers=2) as executor: | ||||
|                 future1 = executor.submit(_task, tmp_watch, update_handler) | ||||
|                 future2 = executor.submit(_task, blank_watch_no_filters, update_handler) | ||||
|  | ||||
|                 text_after_filter = future1.result() | ||||
|                 text_before_filter = future2.result() | ||||
|  | ||||
|     try: | ||||
|         trigger_line_numbers = html_tools.strip_ignore_text(content=text_after_filter, | ||||
|                                                             wordlist=tmp_watch['trigger_text'], | ||||
|                                                             mode='line numbers' | ||||
|                                                             ) | ||||
|     except Exception as e: | ||||
|         text_before_filter = f"Error: {str(e)}" | ||||
|  | ||||
|     try: | ||||
|         text_to_ignore = tmp_watch.get('ignore_text', []) + datastore.data['settings']['application'].get('global_ignore_text', []) | ||||
|         ignore_line_numbers = html_tools.strip_ignore_text(content=text_after_filter, | ||||
|                                                            wordlist=text_to_ignore, | ||||
|                                                            mode='line numbers' | ||||
|                                                            ) | ||||
|     except Exception as e: | ||||
|         text_before_filter = f"Error: {str(e)}" | ||||
|  | ||||
|     logger.trace(f"Parsed in {time.time() - now:.3f}s") | ||||
|  | ||||
|     return jsonify( | ||||
|         { | ||||
|             'after_filter': text_after_filter, | ||||
|             'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter, | ||||
|             'duration': time.time() - now, | ||||
|             'trigger_line_numbers': trigger_line_numbers, | ||||
|             'ignore_line_numbers': ignore_line_numbers, | ||||
|         } | ||||
|     ) | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,7 @@ import re | ||||
| import urllib3 | ||||
|  | ||||
| from changedetectionio.processors import difference_detection_processor | ||||
| from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text | ||||
| 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 loguru import logger | ||||
| @@ -35,7 +35,7 @@ class PDFToHTMLToolNotFound(ValueError): | ||||
| # (set_proxy_from_list) | ||||
| class perform_site_check(difference_detection_processor): | ||||
|  | ||||
|     def run_changedetection(self, watch, skip_when_checksum_same=True): | ||||
|     def run_changedetection(self, watch): | ||||
|         changed_detected = False | ||||
|         html_content = "" | ||||
|         screenshot = False  # as bytes | ||||
| @@ -58,9 +58,6 @@ class perform_site_check(difference_detection_processor): | ||||
|         # Watches added automatically in the queue manager will skip if its the same checksum as the previous run | ||||
|         # Saves a lot of CPU | ||||
|         update_obj['previous_md5_before_filters'] = hashlib.md5(self.fetcher.content.encode('utf-8')).hexdigest() | ||||
|         if skip_when_checksum_same: | ||||
|             if update_obj['previous_md5_before_filters'] == watch.get('previous_md5_before_filters'): | ||||
|                 raise content_fetchers.exceptions.checksumFromPreviousCheckWasTheSame() | ||||
|  | ||||
|         # Fetching complete, now filters | ||||
|  | ||||
| @@ -175,13 +172,13 @@ class perform_site_check(difference_detection_processor): | ||||
|                                                                     html_content=self.fetcher.content, | ||||
|                                                                     append_pretty_line_formatting=not watch.is_source_type_url, | ||||
|                                                                     is_rss=is_rss) | ||||
|  | ||||
|                         elif filter_rule.startswith('xpath1:'): | ||||
|                             html_content += html_tools.xpath1_filter(xpath_filter=filter_rule.replace('xpath1:', ''), | ||||
|                                                                     html_content=self.fetcher.content, | ||||
|                                                                     append_pretty_line_formatting=not watch.is_source_type_url, | ||||
|                                                                     is_rss=is_rss) | ||||
|                                                                      html_content=self.fetcher.content, | ||||
|                                                                      append_pretty_line_formatting=not watch.is_source_type_url, | ||||
|                                                                      is_rss=is_rss) | ||||
|                         else: | ||||
|                             # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text | ||||
|                             html_content += html_tools.include_filters(include_filters=filter_rule, | ||||
|                                                                        html_content=self.fetcher.content, | ||||
|                                                                        append_pretty_line_formatting=not watch.is_source_type_url) | ||||
| @@ -197,25 +194,21 @@ class perform_site_check(difference_detection_processor): | ||||
|                 else: | ||||
|                     # extract text | ||||
|                     do_anchor = self.datastore.data["settings"]["application"].get("render_anchor_tag_content", False) | ||||
|                     stripped_text_from_html = \ | ||||
|                         html_tools.html_to_text( | ||||
|                             html_content=html_content, | ||||
|                             render_anchor_tag_content=do_anchor, | ||||
|                             is_rss=is_rss # #1874 activate the <title workaround hack | ||||
|                         ) | ||||
|                     stripped_text_from_html = html_tools.html_to_text(html_content=html_content, | ||||
|                                                                       render_anchor_tag_content=do_anchor, | ||||
|                                                                       is_rss=is_rss)  # 1874 activate the <title workaround hack | ||||
|  | ||||
|         if watch.get('sort_text_alphabetically') and stripped_text_from_html: | ||||
|             # Note: Because a <p>something</p> will add an extra line feed to signify the paragraph gap | ||||
|             # we end up with 'Some text\n\n', sorting will add all those extra \n at the start, so we remove them here. | ||||
|             stripped_text_from_html = stripped_text_from_html.replace('\n\n', '\n') | ||||
|             stripped_text_from_html = '\n'.join( sorted(stripped_text_from_html.splitlines(), key=lambda x: x.lower() )) | ||||
|         if watch.get('trim_text_whitespace'): | ||||
|             stripped_text_from_html = '\n'.join(line.strip() for line in stripped_text_from_html.replace("\n\n", "\n").splitlines()) | ||||
|  | ||||
|         # Re #340 - return the content before the 'ignore text' was applied | ||||
|         text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8') | ||||
|         # Also used to calculate/show what was removed | ||||
|         text_content_before_ignored_filter = stripped_text_from_html | ||||
|  | ||||
|         # @todo whitespace coming from missing rtrim()? | ||||
|         # stripped_text_from_html could be based on their preferences, replace the processed text with only that which they want to know about. | ||||
|         # Rewrite's the processing text based on only what diff result they want to see | ||||
|  | ||||
|         if watch.has_special_diff_filter_options_set() and len(watch.history.keys()): | ||||
|             # Now the content comes from the diff-parser and not the returned HTTP traffic, so could be some differences | ||||
|             from changedetectionio import diff | ||||
| @@ -230,12 +223,12 @@ class perform_site_check(difference_detection_processor): | ||||
|                                              line_feed_sep="\n", | ||||
|                                              include_change_type_prefix=False) | ||||
|  | ||||
|             watch.save_last_text_fetched_before_filters(text_content_before_ignored_filter) | ||||
|             watch.save_last_text_fetched_before_filters(text_content_before_ignored_filter.encode('utf-8')) | ||||
|  | ||||
|             if not rendered_diff and stripped_text_from_html: | ||||
|                 # We had some content, but no differences were found | ||||
|                 # Store our new file as the MD5 so it will trigger in the future | ||||
|                 c = hashlib.md5(text_content_before_ignored_filter.translate(None, b'\r\n\t ')).hexdigest() | ||||
|                 c = hashlib.md5(stripped_text_from_html.translate(TRANSLATE_WHITESPACE_TABLE).encode('utf-8')).hexdigest() | ||||
|                 return False, {'previous_md5': c}, stripped_text_from_html.encode('utf-8') | ||||
|             else: | ||||
|                 stripped_text_from_html = rendered_diff | ||||
| @@ -256,14 +249,6 @@ class perform_site_check(difference_detection_processor): | ||||
|  | ||||
|         update_obj["last_check_status"] = self.fetcher.get_last_status_code() | ||||
|  | ||||
|         # If there's text to skip | ||||
|         # @todo we could abstract out the get_text() to handle this cleaner | ||||
|         text_to_ignore = watch.get('ignore_text', []) + self.datastore.data['settings']['application'].get('global_ignore_text', []) | ||||
|         if len(text_to_ignore): | ||||
|             stripped_text_from_html = html_tools.strip_ignore_text(stripped_text_from_html, text_to_ignore) | ||||
|         else: | ||||
|             stripped_text_from_html = stripped_text_from_html.encode('utf8') | ||||
|  | ||||
|         # 615 Extract text by regex | ||||
|         extract_text = watch.get('extract_text', []) | ||||
|         if len(extract_text) > 0: | ||||
| @@ -272,37 +257,53 @@ class perform_site_check(difference_detection_processor): | ||||
|                 # incase they specified something in '/.../x' | ||||
|                 if re.search(PERL_STYLE_REGEX, s_re, re.IGNORECASE): | ||||
|                     regex = html_tools.perl_style_slash_enclosed_regex_to_options(s_re) | ||||
|                     result = re.findall(regex.encode('utf-8'), stripped_text_from_html) | ||||
|                     result = re.findall(regex, stripped_text_from_html) | ||||
|  | ||||
|                     for l in result: | ||||
|                         if type(l) is tuple: | ||||
|                             # @todo - some formatter option default (between groups) | ||||
|                             regex_matched_output += list(l) + [b'\n'] | ||||
|                             regex_matched_output += list(l) + ['\n'] | ||||
|                         else: | ||||
|                             # @todo - some formatter option default (between each ungrouped result) | ||||
|                             regex_matched_output += [l] + [b'\n'] | ||||
|                             regex_matched_output += [l] + ['\n'] | ||||
|                 else: | ||||
|                     # Doesnt look like regex, just hunt for plaintext and return that which matches | ||||
|                     # `stripped_text_from_html` will be bytes, so we must encode s_re also to bytes | ||||
|                     r = re.compile(re.escape(s_re.encode('utf-8')), re.IGNORECASE) | ||||
|                     r = re.compile(re.escape(s_re), re.IGNORECASE) | ||||
|                     res = r.findall(stripped_text_from_html) | ||||
|                     if res: | ||||
|                         for match in res: | ||||
|                             regex_matched_output += [match] + [b'\n'] | ||||
|                             regex_matched_output += [match] + ['\n'] | ||||
|  | ||||
|             ########################################################## | ||||
|             stripped_text_from_html = '' | ||||
|  | ||||
|             # Now we will only show what the regex matched | ||||
|             stripped_text_from_html = b'' | ||||
|             text_content_before_ignored_filter = b'' | ||||
|             if regex_matched_output: | ||||
|                 # @todo some formatter for presentation? | ||||
|                 stripped_text_from_html = b''.join(regex_matched_output) | ||||
|                 text_content_before_ignored_filter = stripped_text_from_html | ||||
|                 stripped_text_from_html = ''.join(regex_matched_output) | ||||
|  | ||||
|         if watch.get('remove_duplicate_lines'): | ||||
|             stripped_text_from_html = '\n'.join(dict.fromkeys(line for line in stripped_text_from_html.replace("\n\n", "\n").splitlines())) | ||||
|  | ||||
|  | ||||
|         if watch.get('sort_text_alphabetically'): | ||||
|             # Note: Because a <p>something</p> will add an extra line feed to signify the paragraph gap | ||||
|             # we end up with 'Some text\n\n', sorting will add all those extra \n at the start, so we remove them here. | ||||
|             stripped_text_from_html = stripped_text_from_html.replace("\n\n", "\n") | ||||
|             stripped_text_from_html = '\n'.join(sorted(stripped_text_from_html.splitlines(), key=lambda x: x.lower())) | ||||
|  | ||||
| ### CALCULATE MD5 | ||||
|         # If there's text to ignore | ||||
|         text_to_ignore = watch.get('ignore_text', []) + self.datastore.data['settings']['application'].get('global_ignore_text', []) | ||||
|         text_for_checksuming = stripped_text_from_html | ||||
|         if text_to_ignore: | ||||
|             text_for_checksuming = html_tools.strip_ignore_text(stripped_text_from_html, text_to_ignore) | ||||
|  | ||||
|         # Re #133 - if we should strip whitespaces from triggering the change detected comparison | ||||
|         if self.datastore.data['settings']['application'].get('ignore_whitespace', False): | ||||
|             fetched_md5 = hashlib.md5(stripped_text_from_html.translate(None, b'\r\n\t ')).hexdigest() | ||||
|         if text_for_checksuming and self.datastore.data['settings']['application'].get('ignore_whitespace', False): | ||||
|             fetched_md5 = hashlib.md5(text_for_checksuming.translate(TRANSLATE_WHITESPACE_TABLE).encode('utf-8')).hexdigest() | ||||
|         else: | ||||
|             fetched_md5 = hashlib.md5(stripped_text_from_html).hexdigest() | ||||
|             fetched_md5 = hashlib.md5(text_for_checksuming.encode('utf-8')).hexdigest() | ||||
|  | ||||
|         ############ Blocking rules, after checksum ################# | ||||
|         blocked = False | ||||
| @@ -330,19 +331,33 @@ class perform_site_check(difference_detection_processor): | ||||
|             if result: | ||||
|                 blocked = True | ||||
|  | ||||
|         # The main thing that all this at the moment comes down to :) | ||||
|         if watch.get('previous_md5') != fetched_md5: | ||||
|             changed_detected = True | ||||
|  | ||||
|         # Looks like something changed, but did it match all the rules? | ||||
|         if blocked: | ||||
|             changed_detected = False | ||||
|         else: | ||||
|             # The main thing that all this at the moment comes down to :) | ||||
|             if watch.get('previous_md5') != fetched_md5: | ||||
|                 changed_detected = True | ||||
|  | ||||
|             # Always record the new checksum | ||||
|             update_obj["previous_md5"] = fetched_md5 | ||||
|  | ||||
|             # On the first run of a site, watch['previous_md5'] will be None, set it the current one. | ||||
|             if not watch.get('previous_md5'): | ||||
|                 watch['previous_md5'] = fetched_md5 | ||||
|  | ||||
|         logger.debug(f"Watch UUID {watch.get('uuid')} content check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}") | ||||
|  | ||||
|         if changed_detected: | ||||
|             if watch.get('check_unique_lines', False): | ||||
|                 has_unique_lines = watch.lines_contain_something_unique_compared_to_history(lines=stripped_text_from_html.splitlines()) | ||||
|                 ignore_whitespace = self.datastore.data['settings']['application'].get('ignore_whitespace') | ||||
|  | ||||
|                 has_unique_lines = watch.lines_contain_something_unique_compared_to_history( | ||||
|                     lines=stripped_text_from_html.splitlines(), | ||||
|                     ignore_whitespace=ignore_whitespace | ||||
|                 ) | ||||
|  | ||||
|                 # One or more lines? unsure? | ||||
|                 if not has_unique_lines: | ||||
|                     logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} didnt have anything new setting change_detected=False") | ||||
| @@ -350,11 +365,6 @@ class perform_site_check(difference_detection_processor): | ||||
|                 else: | ||||
|                     logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} had unique content") | ||||
|  | ||||
|         # Always record the new checksum | ||||
|         update_obj["previous_md5"] = fetched_md5 | ||||
|  | ||||
|         # On the first run of a site, watch['previous_md5'] will be None, set it the current one. | ||||
|         if not watch.get('previous_md5'): | ||||
|             watch['previous_md5'] = fetched_md5 | ||||
|  | ||||
|         return changed_detected, update_obj, text_content_before_ignored_filter | ||||
|         # stripped_text_from_html - Everything after filters and NO 'ignored' content | ||||
|         return changed_detected, update_obj, stripped_text_from_html | ||||
|   | ||||
| @@ -16,25 +16,31 @@ echo "---------------------------------- SOCKS5 -------------------" | ||||
| docker run --network changedet-network \ | ||||
|   -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json \ | ||||
|   --rm \ | ||||
|   -e "FLASK_SERVER_NAME=cdio" \ | ||||
|   --hostname cdio \ | ||||
|   -e "SOCKSTEST=proxiesjson" \ | ||||
|   test-changedetectionio \ | ||||
|   bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy_sources.py' | ||||
|   bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004  -s tests/proxy_socks5/test_socks5_proxy_sources.py' | ||||
|  | ||||
| # SOCKS5 related - by manually entering in UI | ||||
| docker run --network changedet-network \ | ||||
|   --rm \ | ||||
|   -e "FLASK_SERVER_NAME=cdio" \ | ||||
|   --hostname cdio \ | ||||
|   -e "SOCKSTEST=manual" \ | ||||
|   test-changedetectionio \ | ||||
|   bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy.py' | ||||
|   bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004  -s tests/proxy_socks5/test_socks5_proxy.py' | ||||
|  | ||||
| # SOCKS5 related - test from proxies.json via playwright - NOTE- PLAYWRIGHT DOESNT SUPPORT AUTHENTICATING PROXY | ||||
| docker run --network changedet-network \ | ||||
|   -e "SOCKSTEST=manual-playwright" \ | ||||
|   --hostname cdio \ | ||||
|   -e "FLASK_SERVER_NAME=cdio" \ | ||||
|   -v `pwd`/tests/proxy_socks5/proxies.json-example-noauth:/app/changedetectionio/test-datastore/proxies.json \ | ||||
|   -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" \ | ||||
|   --rm \ | ||||
|   test-changedetectionio \ | ||||
|   bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy_sources.py' | ||||
|   bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004  -s tests/proxy_socks5/test_socks5_proxy_sources.py' | ||||
|  | ||||
| echo "socks5 server logs" | ||||
| docker logs socks5proxy | ||||
|   | ||||
| @@ -18,9 +18,11 @@ $(document).ready(function () { | ||||
|  | ||||
|     }); | ||||
|  | ||||
|     $("#notification-token-toggle").click(function (e) { | ||||
|     $(".toggle-show").click(function (e) { | ||||
|         e.preventDefault(); | ||||
|         $('#notification-tokens-info').toggle(); | ||||
|         let target = $(this).data('target'); | ||||
|         $(target).toggle(); | ||||
|     }); | ||||
|  | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -1,56 +0,0 @@ | ||||
| /** | ||||
|  * debounce | ||||
|  * @param {integer} milliseconds This param indicates the number of milliseconds | ||||
|  *     to wait after the last call before calling the original function. | ||||
|  * @param {object} What "this" refers to in the returned function. | ||||
|  * @return {function} This returns a function that when called will wait the | ||||
|  *     indicated number of milliseconds after the last call before | ||||
|  *     calling the original function. | ||||
|  */ | ||||
| Function.prototype.debounce = function (milliseconds, context) { | ||||
|     var baseFunction = this, | ||||
|         timer = null, | ||||
|         wait = milliseconds; | ||||
|  | ||||
|     return function () { | ||||
|         var self = context || this, | ||||
|             args = arguments; | ||||
|  | ||||
|         function complete() { | ||||
|             baseFunction.apply(self, args); | ||||
|             timer = null; | ||||
|         } | ||||
|  | ||||
|         if (timer) { | ||||
|             clearTimeout(timer); | ||||
|         } | ||||
|  | ||||
|         timer = setTimeout(complete, wait); | ||||
|     }; | ||||
| }; | ||||
|  | ||||
| /** | ||||
| * throttle | ||||
| * @param {integer} milliseconds This param indicates the number of milliseconds | ||||
| *     to wait between calls before calling the original function. | ||||
| * @param {object} What "this" refers to in the returned function. | ||||
| * @return {function} This returns a function that when called will wait the | ||||
| *     indicated number of milliseconds between calls before | ||||
| *     calling the original function. | ||||
| */ | ||||
| Function.prototype.throttle = function (milliseconds, context) { | ||||
|     var baseFunction = this, | ||||
|         lastEventTimestamp = null, | ||||
|         limit = milliseconds; | ||||
|  | ||||
|     return function () { | ||||
|         var self = context || this, | ||||
|             args = arguments, | ||||
|             now = Date.now(); | ||||
|  | ||||
|         if (!lastEventTimestamp || now - lastEventTimestamp >= limit) { | ||||
|             lastEventTimestamp = now; | ||||
|             baseFunction.apply(self, args); | ||||
|         } | ||||
|     }; | ||||
| }; | ||||
| @@ -28,17 +28,14 @@ $(document).ready(function() { | ||||
|       url: notification_base_url, | ||||
|       data : data, | ||||
|         statusCode: { | ||||
|         400: function() { | ||||
|             // More than likely the CSRF token was lost when the server restarted | ||||
|           alert("There was a problem processing the request, please reload the page."); | ||||
|         400: function(data) { | ||||
|           // More than likely the CSRF token was lost when the server restarted | ||||
|           alert(data.responseText); | ||||
|         } | ||||
|       } | ||||
|     }).done(function(data){ | ||||
|       console.log(data); | ||||
|       alert(data); | ||||
|     }).fail(function(data){ | ||||
|       console.log(data); | ||||
|       alert('There was an error communicating with the server.'); | ||||
|     }) | ||||
|   }); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										162
									
								
								changedetectionio/static/js/plugins.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								changedetectionio/static/js/plugins.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,162 @@ | ||||
| (function ($) { | ||||
|     /** | ||||
|      * debounce | ||||
|      * @param {integer} milliseconds This param indicates the number of milliseconds | ||||
|      *     to wait after the last call before calling the original function. | ||||
|      * @param {object} What "this" refers to in the returned function. | ||||
|      * @return {function} This returns a function that when called will wait the | ||||
|      *     indicated number of milliseconds after the last call before | ||||
|      *     calling the original function. | ||||
|      */ | ||||
|     Function.prototype.debounce = function (milliseconds, context) { | ||||
|         var baseFunction = this, | ||||
|             timer = null, | ||||
|             wait = milliseconds; | ||||
|  | ||||
|         return function () { | ||||
|             var self = context || this, | ||||
|                 args = arguments; | ||||
|  | ||||
|             function complete() { | ||||
|                 baseFunction.apply(self, args); | ||||
|                 timer = null; | ||||
|             } | ||||
|  | ||||
|             if (timer) { | ||||
|                 clearTimeout(timer); | ||||
|             } | ||||
|  | ||||
|             timer = setTimeout(complete, wait); | ||||
|         }; | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * throttle | ||||
|      * @param {integer} milliseconds This param indicates the number of milliseconds | ||||
|      *     to wait between calls before calling the original function. | ||||
|      * @param {object} What "this" refers to in the returned function. | ||||
|      * @return {function} This returns a function that when called will wait the | ||||
|      *     indicated number of milliseconds between calls before | ||||
|      *     calling the original function. | ||||
|      */ | ||||
|     Function.prototype.throttle = function (milliseconds, context) { | ||||
|         var baseFunction = this, | ||||
|             lastEventTimestamp = null, | ||||
|             limit = milliseconds; | ||||
|  | ||||
|         return function () { | ||||
|             var self = context || this, | ||||
|                 args = arguments, | ||||
|                 now = Date.now(); | ||||
|  | ||||
|             if (!lastEventTimestamp || now - lastEventTimestamp >= limit) { | ||||
|                 lastEventTimestamp = now; | ||||
|                 baseFunction.apply(self, args); | ||||
|             } | ||||
|         }; | ||||
|     }; | ||||
|  | ||||
|     $.fn.highlightLines = function (configurations) { | ||||
|         return this.each(function () { | ||||
|             const $pre = $(this); | ||||
|             const textContent = $pre.text(); | ||||
|             const lines = textContent.split(/\r?\n/); // Handles both \n and \r\n line endings | ||||
|  | ||||
|             // Build a map of line numbers to styles | ||||
|             const lineStyles = {}; | ||||
|  | ||||
|             configurations.forEach(config => { | ||||
|                 const {color, lines: lineNumbers} = config; | ||||
|                 lineNumbers.forEach(lineNumber => { | ||||
|                     lineStyles[lineNumber] = color; | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             // Function to escape HTML characters | ||||
|             function escapeHtml(text) { | ||||
|                 return text.replace(/[&<>"'`=\/]/g, function (s) { | ||||
|                     return "&#" + s.charCodeAt(0) + ";"; | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             // Process each line | ||||
|             const processedLines = lines.map((line, index) => { | ||||
|                 const lineNumber = index + 1; // Line numbers start at 1 | ||||
|                 const escapedLine = escapeHtml(line); | ||||
|                 const color = lineStyles[lineNumber]; | ||||
|  | ||||
|                 if (color) { | ||||
|                     // Wrap the line in a span with inline style | ||||
|                     return `<span style="background-color: ${color}">${escapedLine}</span>`; | ||||
|                 } else { | ||||
|                     return escapedLine; | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             // Join the lines back together | ||||
|             const newContent = processedLines.join('\n'); | ||||
|  | ||||
|             // Set the new content as HTML | ||||
|             $pre.html(newContent); | ||||
|         }); | ||||
|     }; | ||||
|     $.fn.miniTabs = function (tabsConfig, options) { | ||||
|         const settings = { | ||||
|             tabClass: 'minitab', | ||||
|             tabsContainerClass: 'minitabs', | ||||
|             activeClass: 'active', | ||||
|             ...(options || {}) | ||||
|         }; | ||||
|  | ||||
|         return this.each(function () { | ||||
|             const $wrapper = $(this); | ||||
|             const $contents = $wrapper.find('div[id]').hide(); | ||||
|             const $tabsContainer = $('<div>', {class: settings.tabsContainerClass}).prependTo($wrapper); | ||||
|  | ||||
|             // Generate tabs | ||||
|             Object.entries(tabsConfig).forEach(([tabTitle, contentSelector], index) => { | ||||
|                 const $content = $wrapper.find(contentSelector); | ||||
|                 if (index === 0) $content.show(); // Show first content by default | ||||
|  | ||||
|                 $('<a>', { | ||||
|                     class: `${settings.tabClass}${index === 0 ? ` ${settings.activeClass}` : ''}`, | ||||
|                     text: tabTitle, | ||||
|                     'data-target': contentSelector | ||||
|                 }).appendTo($tabsContainer); | ||||
|             }); | ||||
|  | ||||
|             // Tab click event | ||||
|             $tabsContainer.on('click', `.${settings.tabClass}`, function (e) { | ||||
|                 e.preventDefault(); | ||||
|                 const $tab = $(this); | ||||
|                 const target = $tab.data('target'); | ||||
|  | ||||
|                 // Update active tab | ||||
|                 $tabsContainer.find(`.${settings.tabClass}`).removeClass(settings.activeClass); | ||||
|                 $tab.addClass(settings.activeClass); | ||||
|  | ||||
|                 // Show/hide content | ||||
|                 $contents.hide(); | ||||
|                 $wrapper.find(target).show(); | ||||
|             }); | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     // Object to store ongoing requests by namespace | ||||
|     const requests = {}; | ||||
|  | ||||
|     $.abortiveSingularAjax = function (options) { | ||||
|         const namespace = options.namespace || 'default'; | ||||
|  | ||||
|         // Abort the current request in this namespace if it's still ongoing | ||||
|         if (requests[namespace]) { | ||||
|             requests[namespace].abort(); | ||||
|         } | ||||
|  | ||||
|         // Start a new AJAX request and store its reference in the correct namespace | ||||
|         requests[namespace] = $.ajax(options); | ||||
|  | ||||
|         // Return the current request in case it's needed | ||||
|         return requests[namespace]; | ||||
|     }; | ||||
| })(jQuery); | ||||
| @@ -1,53 +1,63 @@ | ||||
| function redirect_to_version(version) { | ||||
|     var currentUrl = window.location.href; | ||||
|     var baseUrl = currentUrl.split('?')[0]; // Base URL without query parameters | ||||
| function redirectToVersion(version) { | ||||
|     var currentUrl = window.location.href.split('?')[0]; // Base URL without query parameters | ||||
|     var anchor = ''; | ||||
|  | ||||
|     // Check if there is an anchor | ||||
|     if (baseUrl.indexOf('#') !== -1) { | ||||
|         anchor = baseUrl.substring(baseUrl.indexOf('#')); | ||||
|         baseUrl = baseUrl.substring(0, baseUrl.indexOf('#')); | ||||
|     if (currentUrl.indexOf('#') !== -1) { | ||||
|         anchor = currentUrl.substring(currentUrl.indexOf('#')); | ||||
|         currentUrl = currentUrl.substring(0, currentUrl.indexOf('#')); | ||||
|     } | ||||
|     window.location.href = baseUrl + '?version=' + version + anchor; | ||||
|  | ||||
|     window.location.href = currentUrl + '?version=' + version + anchor; | ||||
| } | ||||
|  | ||||
| document.addEventListener('keydown', function (event) { | ||||
|     var selectElement = document.getElementById('preview-version'); | ||||
|     if (selectElement) { | ||||
|         var selectedOption = selectElement.querySelector('option:checked'); | ||||
|         if (selectedOption) { | ||||
|             if (event.key === 'ArrowLeft') { | ||||
|                 if (selectedOption.previousElementSibling) { | ||||
|                     redirect_to_version(selectedOption.previousElementSibling.value); | ||||
|                 } | ||||
|             } else if (event.key === 'ArrowRight') { | ||||
|                 if (selectedOption.nextElementSibling) { | ||||
|                     redirect_to_version(selectedOption.nextElementSibling.value); | ||||
|                 } | ||||
| function setupDateWidget() { | ||||
|     $(document).on('keydown', function (event) { | ||||
|         var $selectElement = $('#preview-version'); | ||||
|         var $selectedOption = $selectElement.find('option:selected'); | ||||
|  | ||||
|         if ($selectedOption.length) { | ||||
|             if (event.key === 'ArrowLeft' && $selectedOption.prev().length) { | ||||
|                 redirectToVersion($selectedOption.prev().val()); | ||||
|             } else if (event.key === 'ArrowRight' && $selectedOption.next().length) { | ||||
|                 redirectToVersion($selectedOption.next().val()); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| }); | ||||
|     }); | ||||
|  | ||||
|     $('#preview-version').on('change', function () { | ||||
|         redirectToVersion($(this).val()); | ||||
|     }); | ||||
|  | ||||
| document.getElementById('preview-version').addEventListener('change', function () { | ||||
|     redirect_to_version(this.value); | ||||
| }); | ||||
|     var $selectedOption = $('#preview-version option:selected'); | ||||
|  | ||||
| var selectElement = document.getElementById('preview-version'); | ||||
| if (selectElement) { | ||||
|     var selectedOption = selectElement.querySelector('option:checked'); | ||||
|     if (selectedOption) { | ||||
|         if (selectedOption.previousElementSibling) { | ||||
|             document.getElementById('btn-previous').href = "?version=" + selectedOption.previousElementSibling.value; | ||||
|     if ($selectedOption.length) { | ||||
|         var $prevOption = $selectedOption.prev(); | ||||
|         var $nextOption = $selectedOption.next(); | ||||
|  | ||||
|         if ($prevOption.length) { | ||||
|             $('#btn-previous').attr('href', '?version=' + $prevOption.val()); | ||||
|         } else { | ||||
|             document.getElementById('btn-previous').remove() | ||||
|         } | ||||
|         if (selectedOption.nextElementSibling) { | ||||
|             document.getElementById('btn-next').href = "?version=" + selectedOption.nextElementSibling.value; | ||||
|         } else { | ||||
|             document.getElementById('btn-next').remove() | ||||
|             $('#btn-previous').remove(); | ||||
|         } | ||||
|  | ||||
|         if ($nextOption.length) { | ||||
|             $('#btn-next').attr('href', '?version=' + $nextOption.val()); | ||||
|         } else { | ||||
|             $('#btn-next').remove(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| $(document).ready(function () { | ||||
|     if ($('#preview-version').length) { | ||||
|         setupDateWidget(); | ||||
|     } | ||||
|  | ||||
|     $('#diff-col > pre').highlightLines([ | ||||
|         { | ||||
|             'color': '#ee0000', | ||||
|             'lines': triggered_line_numbers | ||||
|         } | ||||
|     ]); | ||||
| }); | ||||
|   | ||||
| @@ -1,14 +1,14 @@ | ||||
| $(function () { | ||||
|     /* add container before each proxy location to show status */ | ||||
|  | ||||
|     var option_li = $('.fetch-backend-proxy li').filter(function() { | ||||
|         return $("input",this)[0].value.length >0; | ||||
|     }); | ||||
|  | ||||
|     //var option_li = $('.fetch-backend-proxy li'); | ||||
|     var isActive = false; | ||||
|     $(option_li).prepend('<div class="proxy-status"></div>'); | ||||
|     $(option_li).append('<div class="proxy-timing"></div><div class="proxy-check-details"></div>'); | ||||
|  | ||||
|     function setup_html_widget() { | ||||
|         var option_li = $('.fetch-backend-proxy li').filter(function () { | ||||
|             return $("input", this)[0].value.length > 0; | ||||
|         }); | ||||
|         $(option_li).prepend('<div class="proxy-status"></div>'); | ||||
|         $(option_li).append('<div class="proxy-timing"></div><div class="proxy-check-details"></div>'); | ||||
|     } | ||||
|  | ||||
|     function set_proxy_check_status(proxy_key, state) { | ||||
|         // select input by value name | ||||
| @@ -59,8 +59,14 @@ $(function () { | ||||
|     } | ||||
|  | ||||
|     $('#check-all-proxies').click(function (e) { | ||||
|  | ||||
|         e.preventDefault() | ||||
|         $('body').addClass('proxy-check-active'); | ||||
|  | ||||
|         if (!$('body').hasClass('proxy-check-active')) { | ||||
|             setup_html_widget(); | ||||
|             $('body').addClass('proxy-check-active'); | ||||
|         } | ||||
|  | ||||
|         $('.proxy-check-details').html(''); | ||||
|         $('.proxy-status').html('<span class="spinner"></span>').fadeIn(); | ||||
|         $('.proxy-timing').html(''); | ||||
|   | ||||
| @@ -26,8 +26,7 @@ function set_active_tab() { | ||||
|     if (tab.length) { | ||||
|         tab[0].parentElement.className = "active"; | ||||
|     } | ||||
|     // hash could move the page down | ||||
|     window.scrollTo(0, 0); | ||||
|  | ||||
| } | ||||
|  | ||||
| function focus_error_tab() { | ||||
|   | ||||
| @@ -49,4 +49,9 @@ $(document).ready(function () { | ||||
|         $("#overlay").toggleClass('visible'); | ||||
|         heartpath.style.fill = document.getElementById("overlay").classList.contains("visible") ? '#ff0000' : 'var(--color-background)'; | ||||
|     }); | ||||
|  | ||||
|     setInterval(function () { | ||||
|         $('body').toggleClass('spinner-active', $.active > 0); | ||||
|     }, 2000); | ||||
|  | ||||
| }); | ||||
|   | ||||
| @@ -12,6 +12,51 @@ function toggleOpacity(checkboxSelector, fieldSelector, inverted) { | ||||
|     checkbox.addEventListener('change', updateOpacity); | ||||
| } | ||||
|  | ||||
|  | ||||
| function request_textpreview_update() { | ||||
|     if (!$('body').hasClass('preview-text-enabled')) { | ||||
|         console.error("Preview text was requested but body tag was not setup") | ||||
|         return | ||||
|     } | ||||
|  | ||||
|     const data = {}; | ||||
|     $('textarea:visible, input:visible').each(function () { | ||||
|         const $element = $(this); // Cache the jQuery object for the current element | ||||
|         const name = $element.attr('name'); // Get the name attribute of the element | ||||
|         data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val(); | ||||
|     }); | ||||
|  | ||||
|     $('body').toggleClass('spinner-active', 1); | ||||
|  | ||||
|     $.abortiveSingularAjax({ | ||||
|         type: "POST", | ||||
|         url: preview_text_edit_filters_url, | ||||
|         data: data, | ||||
|         namespace: 'watchEdit' | ||||
|     }).done(function (data) { | ||||
|         console.debug(data['duration']) | ||||
|         $('#filters-and-triggers #text-preview-before-inner').text(data['before_filter']); | ||||
|         $('#filters-and-triggers #text-preview-inner') | ||||
|             .text(data['after_filter']) | ||||
|             .highlightLines([ | ||||
|                 { | ||||
|                     'color': '#ee0000', | ||||
|                     'lines': data['trigger_line_numbers'] | ||||
|                 }, | ||||
|                 { | ||||
|                     'color': '#757575', | ||||
|                     'lines': data['ignore_line_numbers'] | ||||
|                 } | ||||
|             ]) | ||||
|     }).fail(function (error) { | ||||
|         if (error.statusText === 'abort') { | ||||
|             console.log('Request was aborted due to a new request being fired.'); | ||||
|         } else { | ||||
|             $('#filters-and-triggers #text-preview-inner').text('There was an error communicating with the server.'); | ||||
|         } | ||||
|     }) | ||||
| } | ||||
|  | ||||
| $(document).ready(function () { | ||||
|     $('#notification-setting-reset-to-default').click(function (e) { | ||||
|         $('#notification_title').val(''); | ||||
| @@ -27,5 +72,21 @@ $(document).ready(function () { | ||||
|  | ||||
|     toggleOpacity('#time_between_check_use_default', '#time_between_check', false); | ||||
|  | ||||
|     const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); | ||||
|     $("#text-preview-inner").css('max-height', (vh-300)+"px"); | ||||
|     $("#text-preview-before-inner").css('max-height', (vh-300)+"px"); | ||||
|  | ||||
|     $("#activate-text-preview").click(function (e) { | ||||
|         $('body').toggleClass('preview-text-enabled') | ||||
|         request_textpreview_update(); | ||||
|         const method = $('body').hasClass('preview-text-enabled') ? 'on' : 'off'; | ||||
|         $('#filters-and-triggers textarea')[method]('blur', request_textpreview_update.throttle(1000)); | ||||
|         $('#filters-and-triggers input')[method]('change', request_textpreview_update.throttle(1000)); | ||||
|         $("#filters-and-triggers-tab")[method]('click', request_textpreview_update.throttle(1000)); | ||||
|     }); | ||||
|     $('.minitabs-wrapper').miniTabs({ | ||||
|         "Content after filters": "#text-preview-inner", | ||||
|         "Content raw/before filters": "#text-preview-before-inner" | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -153,7 +153,8 @@ html[data-darkmode="true"] { | ||||
|     border: 1px solid transparent; | ||||
|     vertical-align: top; | ||||
|     font: 1em monospace; | ||||
|     text-align: left; } | ||||
|     text-align: left; | ||||
|     overflow: clip; } | ||||
|   #diff-ui pre { | ||||
|     white-space: pre-wrap; } | ||||
|  | ||||
| @@ -172,7 +173,9 @@ ins { | ||||
|   text-decoration: none; } | ||||
|  | ||||
| #result { | ||||
|   white-space: pre-wrap; } | ||||
|   white-space: pre-wrap; | ||||
|   word-break: break-word; | ||||
|   overflow-wrap: break-word; } | ||||
|  | ||||
| #settings { | ||||
|   background: rgba(0, 0, 0, 0.05); | ||||
| @@ -231,3 +234,12 @@ td#diff-col div { | ||||
|   border-radius: 5px; | ||||
|   background: var(--color-background); | ||||
|   box-shadow: 1px 1px 4px var(--color-shadow-jump); } | ||||
|  | ||||
| .pure-form button.reset-margin { | ||||
|   margin: 0px; } | ||||
|  | ||||
| .diff-fieldset { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 4px; | ||||
|   flex-wrap: wrap; } | ||||
|   | ||||
| @@ -24,6 +24,7 @@ | ||||
|     vertical-align: top; | ||||
|     font: 1em monospace; | ||||
|     text-align: left; | ||||
|     overflow: clip; // clip overflowing contents to cell boundariess | ||||
|   } | ||||
|  | ||||
|   pre { | ||||
| @@ -50,6 +51,8 @@ ins { | ||||
|  | ||||
| #result { | ||||
|   white-space: pre-wrap; | ||||
|   word-break: break-word; | ||||
|   overflow-wrap: break-word; | ||||
|  | ||||
|   .change { | ||||
|     span {} | ||||
| @@ -134,3 +137,15 @@ td#diff-col div { | ||||
|   background: var(--color-background); | ||||
|   box-shadow: 1px 1px 4px var(--color-shadow-jump); | ||||
| } | ||||
|  | ||||
| // resets button margin to 0px | ||||
| .pure-form button.reset-margin { | ||||
|   margin: 0px; | ||||
| } | ||||
|  | ||||
| .diff-fieldset { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 4px; | ||||
|   flex-wrap: wrap; | ||||
| } | ||||
| @@ -40,15 +40,29 @@ | ||||
|   } | ||||
| } | ||||
|  | ||||
| #browser-steps-fieldlist { | ||||
|   height: 100%; | ||||
|   overflow-y: scroll; | ||||
| } | ||||
|  | ||||
| #browser-steps .flex-wrapper { | ||||
|   display: flex; | ||||
|   flex-flow: row; | ||||
|   height: 70vh; | ||||
|   font-size: 80%; | ||||
|   #browser-steps-ui { | ||||
|     flex-grow: 1;      /* Allow it to grow and fill the available space */ | ||||
|     flex-shrink: 1;    /* Allow it to shrink if needed */ | ||||
|     flex-basis: 0;     /* Start with 0 base width so it stretches as much as possible */ | ||||
|     background-color: #eee; | ||||
|     border-radius: 5px; | ||||
|  | ||||
|   } | ||||
|  | ||||
|   #browser-steps-fieldlist { | ||||
|     flex-grow: 0;      /* Don't allow it to grow */ | ||||
|     flex-shrink: 0;    /* Don't allow it to shrink */ | ||||
|     flex-basis: auto;  /* Base width is determined by the content */ | ||||
|     max-width: 400px;  /* Set a max width to prevent overflow */ | ||||
|     padding-left: 1rem; | ||||
|     overflow-y: scroll; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /*  this is duplicate :( */ | ||||
|   | ||||
| @@ -11,7 +11,22 @@ ul#requests-extra_browsers { | ||||
|   /* each proxy entry is a `table` */ | ||||
|   table { | ||||
|     tr { | ||||
|       display: inline; | ||||
|       display: table-row; // default display for small screens | ||||
|       input[type=text] { | ||||
|         width: 100%; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   // apply inline display for larger screens | ||||
|   @media only screen and (min-width: 1280px) { | ||||
|     table { | ||||
|       tr { | ||||
|         display: inline; | ||||
|         input[type=text] { | ||||
|           width: 100%; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,19 @@ ul#requests-extra_proxies { | ||||
|   /* each proxy entry is a `table` */ | ||||
|   table { | ||||
|     tr { | ||||
|       display: inline; | ||||
|       display: table-row; // default display for small screens | ||||
|       input[type=text] { | ||||
|         width: 100%; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   // apply inline display for large screens | ||||
|   @media only screen and (min-width: 1024px) { | ||||
|     table { | ||||
|       tr { | ||||
|         display: inline; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -25,15 +37,19 @@ ul#requests-extra_proxies { | ||||
|  | ||||
| body.proxy-check-active { | ||||
|   #request { | ||||
|     // Padding set by flex layout | ||||
|     /* | ||||
|     .proxy-status { | ||||
|       width: 2em; | ||||
|     } | ||||
|     */ | ||||
|  | ||||
|     .proxy-check-details { | ||||
|       font-size: 80%; | ||||
|       color: #555; | ||||
|       display: block; | ||||
|       padding-left: 4em; | ||||
|       padding-left: 2em; | ||||
|       max-width: 500px; | ||||
|     } | ||||
|  | ||||
|     .proxy-timing { | ||||
|   | ||||
							
								
								
									
										47
									
								
								changedetectionio/static/styles/scss/parts/_minitabs.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								changedetectionio/static/styles/scss/parts/_minitabs.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| .minitabs-wrapper { | ||||
|   width: 100%; | ||||
|  | ||||
|   > div[id] { | ||||
|     padding: 20px; | ||||
|     border: 1px solid #ccc; | ||||
|     border-top: none; | ||||
|   } | ||||
|  | ||||
|   .minitabs-content { | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     > div { | ||||
|       flex: 1 1 auto; | ||||
|       min-width: 0; | ||||
|       overflow: scroll; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .minitabs { | ||||
|     display: flex; | ||||
|     border-bottom: 1px solid #ccc; | ||||
|   } | ||||
|  | ||||
|   .minitab { | ||||
|     flex: 1; | ||||
|     text-align: center; | ||||
|     padding: 12px 0; | ||||
|     text-decoration: none; | ||||
|     color: #333; | ||||
|     background-color: #f1f1f1; | ||||
|     border: 1px solid #ccc; | ||||
|     border-bottom: none; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.3s; | ||||
|   } | ||||
|  | ||||
|   .minitab:hover { | ||||
|     background-color: #ddd; | ||||
|   } | ||||
|  | ||||
|   .minitab.active { | ||||
|     background-color: #fff; | ||||
|     font-weight: bold; | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,55 @@ | ||||
| @import "minitabs"; | ||||
|  | ||||
| body.preview-text-enabled { | ||||
|  | ||||
|   @media (min-width: 800px) { | ||||
|     #filters-and-triggers > div { | ||||
|       display: flex; /* Establishes Flexbox layout */ | ||||
|       gap: 20px; /* Adds space between the columns */ | ||||
|       position: relative; /* Ensures the sticky positioning is relative to this parent */ | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /* layout of the page */ | ||||
|   #edit-text-filter, #text-preview { | ||||
|     flex: 1; /* Each column takes an equal amount of available space */ | ||||
|     align-self: flex-start; /* Aligns the right column to the start, allowing it to maintain its content height */ | ||||
|   } | ||||
|  | ||||
|   #edit-text-filter { | ||||
|     #pro-tips { | ||||
|       display: none; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   #text-preview { | ||||
|     position: sticky; | ||||
|     top: 20px; | ||||
|     padding-top: 1rem; | ||||
|     padding-bottom: 1rem; | ||||
|     display: block !important; | ||||
|   } | ||||
|  | ||||
|   #activate-text-preview { | ||||
|       background-color: var(--color-grey-500); | ||||
|   } | ||||
|  | ||||
|   /* actual preview area */ | ||||
|   .monospace-preview { | ||||
|     background: var(--color-background-input); | ||||
|     border: 1px solid var(--color-grey-600); | ||||
|     padding: 1rem; | ||||
|     color: var(--color-text-input); | ||||
|     font-family: "Courier New", Courier, monospace; /* Sets the font to a monospace type */ | ||||
|     font-size: 70%; | ||||
|     word-break: break-word; | ||||
|     white-space: pre-wrap; /* Preserves whitespace and line breaks like <pre> */ | ||||
|   } | ||||
| } | ||||
|  | ||||
| #activate-text-preview { | ||||
|   right: 0; | ||||
|   position: absolute; | ||||
|   z-index: 3; | ||||
|   box-shadow: 1px 1px 4px var(--color-shadow-jump); | ||||
| } | ||||
| @@ -12,6 +12,7 @@ | ||||
| @import "parts/_darkmode"; | ||||
| @import "parts/_menu"; | ||||
| @import "parts/_love"; | ||||
| @import "parts/preview_text_filter"; | ||||
|  | ||||
| body { | ||||
|   color: var(--color-text); | ||||
| @@ -105,10 +106,34 @@ button.toggle-button { | ||||
|   padding: 5px; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   border-bottom: 2px solid var(--color-menu-accent); | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| #pure-menu-horizontal-spinner { | ||||
|   height: 3px; | ||||
|   background: linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000); | ||||
|   background-size: 400% 400%; | ||||
|   width: 100%; | ||||
|   animation: gradient 200s ease infinite; | ||||
| } | ||||
|  | ||||
| body.spinner-active { | ||||
|   #pure-menu-horizontal-spinner { | ||||
|     animation: gradient 1s ease infinite; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @keyframes gradient { | ||||
| 	0% { | ||||
| 		background-position: 0% 50%; | ||||
| 	} | ||||
| 	50% { | ||||
| 		background-position: 100% 50%; | ||||
| 	} | ||||
| 	100% { | ||||
| 		background-position: 0% 50%; | ||||
| 	} | ||||
| } | ||||
| .pure-menu-heading { | ||||
|   color: var(--color-text-menu-heading); | ||||
| } | ||||
| @@ -122,8 +147,14 @@ button.toggle-button { | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
| .tab-pane-inner { | ||||
|   // .tab-pane-inner will have the #id that the tab button jumps/anchors to | ||||
|   scroll-margin-top: 200px; | ||||
| } | ||||
|  | ||||
| section.content { | ||||
|   padding-top: 5em; | ||||
|   padding-top: 100px; | ||||
|   padding-bottom: 1em; | ||||
|   flex-direction: column; | ||||
|   display: flex; | ||||
| @@ -320,10 +351,6 @@ a.pure-button-selected { | ||||
|   background: var(--color-background-button-cancel); | ||||
| } | ||||
|  | ||||
| #save_button { | ||||
|   margin-right: 1rem; | ||||
| } | ||||
|  | ||||
| .messages { | ||||
|   li { | ||||
|     list-style: none; | ||||
| @@ -620,9 +647,9 @@ footer { | ||||
|       list-style: none; | ||||
|  | ||||
|       li { | ||||
|         >* { | ||||
|           display: inline-block; | ||||
|         } | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 1em; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| @@ -682,6 +709,12 @@ footer { | ||||
|       tr { | ||||
|         th { | ||||
|           display: inline-block; | ||||
|           // Hide the "Last" text for smaller screens | ||||
|           @media (max-width: 768px) { | ||||
|             .hide-on-mobile { | ||||
|               display: none;  | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       .empty-cell { | ||||
| @@ -697,6 +730,24 @@ footer { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     tbody { | ||||
|       tr { | ||||
|         display: flex; | ||||
|         flex-wrap: wrap; | ||||
|  | ||||
|         // The third child of each row will take up the remaining space | ||||
|         // This is useful for the URL column, which should expand to fill the remaining space | ||||
|         :nth-child(3) { | ||||
|           flex-grow: 1; | ||||
|         } | ||||
|         // The last three children (from the end) of each row will take up the full width | ||||
|         // This is useful for the "Last Checked", "Last Changed", and the action buttons columns, which should each take up the full width | ||||
|         :nth-last-child(-n+3) { | ||||
|           flex-basis: 100%; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .last-checked { | ||||
|       >span { | ||||
|         vertical-align: middle; | ||||
| @@ -815,6 +866,11 @@ textarea::placeholder { | ||||
| - We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out | ||||
| - Rely always on width in CSS | ||||
| */ | ||||
| /** Set max width for input field */ | ||||
| .m-d { | ||||
|   min-width: 100%; | ||||
| } | ||||
|  | ||||
| @media only screen and (min-width: 761px) { | ||||
|  | ||||
|   /* m-d is medium-desktop */ | ||||
| @@ -881,6 +937,7 @@ $form-edge-padding: 20px; | ||||
| } | ||||
|  | ||||
| .tab-pane-inner { | ||||
|  | ||||
|   &:not(:target) { | ||||
|     display: none; | ||||
|   } | ||||
| @@ -930,6 +987,13 @@ body.full-width { | ||||
|     background: var(--color-background); | ||||
|   } | ||||
|  | ||||
|   /* Make action buttons have consistent size and spacing */ | ||||
|   #actions .pure-control-group { | ||||
|     display: flex; | ||||
|     gap: 0.625em; | ||||
|     flex-wrap: wrap; | ||||
|   } | ||||
|  | ||||
|   .pure-form-message-inline { | ||||
|     padding-left: 0; | ||||
|     color: var(--color-text-input-description); | ||||
| @@ -973,6 +1037,28 @@ ul { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 760px) { | ||||
|   .time-check-widget { | ||||
|     tbody { | ||||
|       display: grid; | ||||
|       grid-template-columns: auto 1fr auto 1fr; | ||||
|       gap: 0.625em 0.3125em; | ||||
|       align-items: center; | ||||
|     }     | ||||
|     tr { | ||||
|       display: contents;  | ||||
|       th { | ||||
|         text-align: right; | ||||
|         padding-right: 5px; | ||||
|       } | ||||
|       input[type="number"] { | ||||
|         width: 100%; | ||||
|         max-width: 5em; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @import "parts/_visualselector"; | ||||
|  | ||||
| #webdriver_delay { | ||||
|   | ||||
| @@ -46,14 +46,31 @@ | ||||
|     #browser_steps li > label { | ||||
|       display: none; } | ||||
|  | ||||
| #browser-steps-fieldlist { | ||||
|   height: 100%; | ||||
|   overflow-y: scroll; } | ||||
|  | ||||
| #browser-steps .flex-wrapper { | ||||
|   display: flex; | ||||
|   flex-flow: row; | ||||
|   height: 70vh; } | ||||
|   height: 70vh; | ||||
|   font-size: 80%; } | ||||
|   #browser-steps .flex-wrapper #browser-steps-ui { | ||||
|     flex-grow: 1; | ||||
|     /* Allow it to grow and fill the available space */ | ||||
|     flex-shrink: 1; | ||||
|     /* Allow it to shrink if needed */ | ||||
|     flex-basis: 0; | ||||
|     /* Start with 0 base width so it stretches as much as possible */ | ||||
|     background-color: #eee; | ||||
|     border-radius: 5px; } | ||||
|   #browser-steps .flex-wrapper #browser-steps-fieldlist { | ||||
|     flex-grow: 0; | ||||
|     /* Don't allow it to grow */ | ||||
|     flex-shrink: 0; | ||||
|     /* Don't allow it to shrink */ | ||||
|     flex-basis: auto; | ||||
|     /* Base width is determined by the content */ | ||||
|     max-width: 400px; | ||||
|     /* Set a max width to prevent overflow */ | ||||
|     padding-left: 1rem; | ||||
|     overflow-y: scroll; } | ||||
|  | ||||
| /*  this is duplicate :( */ | ||||
| #browsersteps-selector-wrapper { | ||||
| @@ -95,26 +112,34 @@ ul#requests-extra_proxies { | ||||
|   ul#requests-extra_proxies li > label { | ||||
|     display: none; } | ||||
|   ul#requests-extra_proxies table tr { | ||||
|     display: inline; } | ||||
|     display: table-row; } | ||||
|     ul#requests-extra_proxies table tr input[type=text] { | ||||
|       width: 100%; } | ||||
|   @media only screen and (min-width: 1024px) { | ||||
|     ul#requests-extra_proxies table tr { | ||||
|       display: inline; } } | ||||
|  | ||||
| #request { | ||||
|   /* Auto proxy scan/checker */ } | ||||
|   #request label[for=proxy] { | ||||
|     display: inline-block; } | ||||
|  | ||||
| body.proxy-check-active #request .proxy-status { | ||||
|   width: 2em; } | ||||
|  | ||||
| body.proxy-check-active #request .proxy-check-details { | ||||
|   font-size: 80%; | ||||
|   color: #555; | ||||
|   display: block; | ||||
|   padding-left: 4em; } | ||||
|  | ||||
| body.proxy-check-active #request .proxy-timing { | ||||
|   font-size: 80%; | ||||
|   padding-left: 1rem; | ||||
|   color: var(--color-link); } | ||||
| body.proxy-check-active #request { | ||||
|   /* | ||||
|     .proxy-status { | ||||
|       width: 2em; | ||||
|     } | ||||
|     */ } | ||||
|   body.proxy-check-active #request .proxy-check-details { | ||||
|     font-size: 80%; | ||||
|     color: #555; | ||||
|     display: block; | ||||
|     padding-left: 2em; | ||||
|     max-width: 500px; } | ||||
|   body.proxy-check-active #request .proxy-timing { | ||||
|     font-size: 80%; | ||||
|     padding-left: 1rem; | ||||
|     color: var(--color-link); } | ||||
|  | ||||
| #recommended-proxy { | ||||
|   display: grid; | ||||
| @@ -141,7 +166,14 @@ ul#requests-extra_browsers { | ||||
|   ul#requests-extra_browsers li > label { | ||||
|     display: none; } | ||||
|   ul#requests-extra_browsers table tr { | ||||
|     display: inline; } | ||||
|     display: table-row; } | ||||
|     ul#requests-extra_browsers table tr input[type=text] { | ||||
|       width: 100%; } | ||||
|   @media only screen and (min-width: 1280px) { | ||||
|     ul#requests-extra_browsers table tr { | ||||
|       display: inline; } | ||||
|       ul#requests-extra_browsers table tr input[type=text] { | ||||
|         width: 100%; } } | ||||
|  | ||||
| #extra-browsers-setting { | ||||
|   border: 1px solid var(--color-grey-800); | ||||
| @@ -411,6 +443,83 @@ html[data-darkmode="true"] #toggle-light-mode .icon-dark { | ||||
|     fill: #ff0000 !important; | ||||
|     transition: all ease 0.3s !important; } | ||||
|  | ||||
| .minitabs-wrapper { | ||||
|   width: 100%; } | ||||
|   .minitabs-wrapper > div[id] { | ||||
|     padding: 20px; | ||||
|     border: 1px solid #ccc; | ||||
|     border-top: none; } | ||||
|   .minitabs-wrapper .minitabs-content { | ||||
|     width: 100%; | ||||
|     display: flex; } | ||||
|     .minitabs-wrapper .minitabs-content > div { | ||||
|       flex: 1 1 auto; | ||||
|       min-width: 0; | ||||
|       overflow: scroll; } | ||||
|   .minitabs-wrapper .minitabs { | ||||
|     display: flex; | ||||
|     border-bottom: 1px solid #ccc; } | ||||
|   .minitabs-wrapper .minitab { | ||||
|     flex: 1; | ||||
|     text-align: center; | ||||
|     padding: 12px 0; | ||||
|     text-decoration: none; | ||||
|     color: #333; | ||||
|     background-color: #f1f1f1; | ||||
|     border: 1px solid #ccc; | ||||
|     border-bottom: none; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.3s; } | ||||
|   .minitabs-wrapper .minitab:hover { | ||||
|     background-color: #ddd; } | ||||
|   .minitabs-wrapper .minitab.active { | ||||
|     background-color: #fff; | ||||
|     font-weight: bold; } | ||||
|  | ||||
| body.preview-text-enabled { | ||||
|   /* layout of the page */ | ||||
|   /* actual preview area */ } | ||||
|   @media (min-width: 800px) { | ||||
|     body.preview-text-enabled #filters-and-triggers > div { | ||||
|       display: flex; | ||||
|       /* Establishes Flexbox layout */ | ||||
|       gap: 20px; | ||||
|       /* Adds space between the columns */ | ||||
|       position: relative; | ||||
|       /* Ensures the sticky positioning is relative to this parent */ } } | ||||
|   body.preview-text-enabled #edit-text-filter, body.preview-text-enabled #text-preview { | ||||
|     flex: 1; | ||||
|     /* Each column takes an equal amount of available space */ | ||||
|     align-self: flex-start; | ||||
|     /* Aligns the right column to the start, allowing it to maintain its content height */ } | ||||
|   body.preview-text-enabled #edit-text-filter #pro-tips { | ||||
|     display: none; } | ||||
|   body.preview-text-enabled #text-preview { | ||||
|     position: sticky; | ||||
|     top: 20px; | ||||
|     padding-top: 1rem; | ||||
|     padding-bottom: 1rem; | ||||
|     display: block !important; } | ||||
|   body.preview-text-enabled #activate-text-preview { | ||||
|     background-color: var(--color-grey-500); } | ||||
|   body.preview-text-enabled .monospace-preview { | ||||
|     background: var(--color-background-input); | ||||
|     border: 1px solid var(--color-grey-600); | ||||
|     padding: 1rem; | ||||
|     color: var(--color-text-input); | ||||
|     font-family: "Courier New", Courier, monospace; | ||||
|     /* Sets the font to a monospace type */ | ||||
|     font-size: 70%; | ||||
|     word-break: break-word; | ||||
|     white-space: pre-wrap; | ||||
|     /* Preserves whitespace and line breaks like <pre> */ } | ||||
|  | ||||
| #activate-text-preview { | ||||
|   right: 0; | ||||
|   position: absolute; | ||||
|   z-index: 3; | ||||
|   box-shadow: 1px 1px 4px var(--color-shadow-jump); } | ||||
|  | ||||
| body { | ||||
|   color: var(--color-text); | ||||
|   background: var(--color-background-page); | ||||
| @@ -479,9 +588,26 @@ button.toggle-button { | ||||
|   padding: 5px; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   border-bottom: 2px solid var(--color-menu-accent); | ||||
|   align-items: center; } | ||||
|  | ||||
| #pure-menu-horizontal-spinner { | ||||
|   height: 3px; | ||||
|   background: linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000); | ||||
|   background-size: 400% 400%; | ||||
|   width: 100%; | ||||
|   animation: gradient 200s ease infinite; } | ||||
|  | ||||
| body.spinner-active #pure-menu-horizontal-spinner { | ||||
|   animation: gradient 1s ease infinite; } | ||||
|  | ||||
| @keyframes gradient { | ||||
|   0% { | ||||
|     background-position: 0% 50%; } | ||||
|   50% { | ||||
|     background-position: 100% 50%; } | ||||
|   100% { | ||||
|     background-position: 0% 50%; } } | ||||
|  | ||||
| .pure-menu-heading { | ||||
|   color: var(--color-text-menu-heading); } | ||||
|  | ||||
| @@ -491,8 +617,11 @@ button.toggle-button { | ||||
|     background-color: var(--color-background-menu-link-hover); | ||||
|     color: var(--color-text-menu-link-hover); } | ||||
|  | ||||
| .tab-pane-inner { | ||||
|   scroll-margin-top: 200px; } | ||||
|  | ||||
| section.content { | ||||
|   padding-top: 5em; | ||||
|   padding-top: 100px; | ||||
|   padding-bottom: 1em; | ||||
|   flex-direction: column; | ||||
|   display: flex; | ||||
| @@ -634,9 +763,6 @@ a.pure-button-selected { | ||||
| .button-cancel { | ||||
|   background: var(--color-background-button-cancel); } | ||||
|  | ||||
| #save_button { | ||||
|   margin-right: 1rem; } | ||||
|  | ||||
| .messages li { | ||||
|   list-style: none; | ||||
|   padding: 1em; | ||||
| @@ -835,8 +961,10 @@ footer { | ||||
|   .pure-form .inline-radio ul { | ||||
|     margin: 0px; | ||||
|     list-style: none; } | ||||
|     .pure-form .inline-radio ul li > * { | ||||
|       display: inline-block; } | ||||
|     .pure-form .inline-radio ul li { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       gap: 1em; } | ||||
|  | ||||
| @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { | ||||
|   .box { | ||||
| @@ -872,12 +1000,24 @@ footer { | ||||
|     .watch-table thead { | ||||
|       display: block; } | ||||
|       .watch-table thead tr th { | ||||
|         display: inline-block; } | ||||
|         display: inline-block; } } | ||||
|       @media only screen and (max-width: 760px) and (max-width: 768px), (min-device-width: 768px) and (max-device-width: 800px) and (max-width: 768px) { | ||||
|         .watch-table thead tr th .hide-on-mobile { | ||||
|           display: none; } } | ||||
|  | ||||
| @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 800px) { | ||||
|       .watch-table thead .empty-cell { | ||||
|         display: none; } | ||||
|     .watch-table tbody td, | ||||
|     .watch-table tbody tr { | ||||
|       display: block; } | ||||
|     .watch-table tbody tr { | ||||
|       display: flex; | ||||
|       flex-wrap: wrap; } | ||||
|       .watch-table tbody tr :nth-child(3) { | ||||
|         flex-grow: 1; } | ||||
|       .watch-table tbody tr :nth-last-child(-n+3) { | ||||
|         flex-basis: 100%; } | ||||
|     .watch-table .last-checked > span { | ||||
|       vertical-align: middle; } | ||||
|     .watch-table .last-checked::before { | ||||
| @@ -969,6 +1109,10 @@ textarea::placeholder { | ||||
| - We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out | ||||
| - Rely always on width in CSS | ||||
| */ | ||||
| /** Set max width for input field */ | ||||
| .m-d { | ||||
|   min-width: 100%; } | ||||
|  | ||||
| @media only screen and (min-width: 761px) { | ||||
|   /* m-d is medium-desktop */ | ||||
|   .m-d { | ||||
| @@ -1029,7 +1173,8 @@ body.full-width .edit-form { | ||||
| .edit-form { | ||||
|   min-width: 70%; | ||||
|   /* so it cant overflow */ | ||||
|   max-width: 95%; } | ||||
|   max-width: 95%; | ||||
|   /* Make action buttons have consistent size and spacing */ } | ||||
|   .edit-form .box-wrap { | ||||
|     position: relative; } | ||||
|   .edit-form .inner { | ||||
| @@ -1038,6 +1183,10 @@ body.full-width .edit-form { | ||||
|   .edit-form #actions { | ||||
|     display: block; | ||||
|     background: var(--color-background); } | ||||
|   .edit-form #actions .pure-control-group { | ||||
|     display: flex; | ||||
|     gap: 0.625em; | ||||
|     flex-wrap: wrap; } | ||||
|   .edit-form .pure-form-message-inline { | ||||
|     padding-left: 0; | ||||
|     color: var(--color-text-input-description); } | ||||
| @@ -1066,6 +1215,21 @@ ul { | ||||
|   .time-check-widget tr input[type="number"] { | ||||
|     width: 5em; } | ||||
|  | ||||
| @media only screen and (max-width: 760px) { | ||||
|   .time-check-widget tbody { | ||||
|     display: grid; | ||||
|     grid-template-columns: auto 1fr auto 1fr; | ||||
|     gap: 0.625em 0.3125em; | ||||
|     align-items: center; } | ||||
|   .time-check-widget tr { | ||||
|     display: contents; } | ||||
|     .time-check-widget tr th { | ||||
|       text-align: right; | ||||
|       padding-right: 5px; } | ||||
|     .time-check-widget tr input[type="number"] { | ||||
|       width: 100%; | ||||
|       max-width: 5em; } } | ||||
|  | ||||
| #selector-wrapper { | ||||
|   height: 100%; | ||||
|   text-align: center; | ||||
| @@ -1194,11 +1358,9 @@ ul { | ||||
|   color: #fff; | ||||
|   opacity: 0.7; } | ||||
|  | ||||
|  | ||||
| .restock-label svg { | ||||
|   vertical-align: middle; } | ||||
|  | ||||
|  | ||||
| #chrome-extension-link { | ||||
|   padding: 9px; | ||||
|   border: 1px solid var(--color-grey-800); | ||||
|   | ||||
| @@ -4,6 +4,7 @@ from flask import ( | ||||
|     flash | ||||
| ) | ||||
|  | ||||
| from .html_tools import TRANSLATE_WHITESPACE_TABLE | ||||
| from . model import App, Watch | ||||
| from copy import deepcopy, copy | ||||
| from os import path, unlink | ||||
| @@ -11,7 +12,6 @@ from threading import Lock | ||||
| import json | ||||
| import os | ||||
| import re | ||||
| import requests | ||||
| import secrets | ||||
| import threading | ||||
| import time | ||||
| @@ -270,6 +270,7 @@ class ChangeDetectionStore: | ||||
|         self.needs_write_urgent = True | ||||
|  | ||||
|     def add_watch(self, url, tag='', extras=None, tag_uuids=None, write_to_disk_now=True): | ||||
|         import requests | ||||
|  | ||||
|         if extras is None: | ||||
|             extras = {} | ||||
| @@ -750,17 +751,17 @@ class ChangeDetectionStore: | ||||
|     def update_5(self): | ||||
|         # If the watch notification body, title look the same as the global one, unset it, so the watch defaults back to using the main settings | ||||
|         # In other words - the watch notification_title and notification_body are not needed if they are the same as the default one | ||||
|         current_system_body = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n ")) | ||||
|         current_system_title = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n ")) | ||||
|         current_system_body = self.data['settings']['application']['notification_body'].translate(TRANSLATE_WHITESPACE_TABLE) | ||||
|         current_system_title = self.data['settings']['application']['notification_body'].translate(TRANSLATE_WHITESPACE_TABLE) | ||||
|         for uuid, watch in self.data['watching'].items(): | ||||
|             try: | ||||
|                 watch_body = watch.get('notification_body', '') | ||||
|                 if watch_body and watch_body.translate(str.maketrans('', '', "\r\n ")) == current_system_body: | ||||
|                 if watch_body and watch_body.translate(TRANSLATE_WHITESPACE_TABLE) == current_system_body: | ||||
|                     # Looks the same as the default one, so unset it | ||||
|                     watch['notification_body'] = None | ||||
|  | ||||
|                 watch_title = watch.get('notification_title', '') | ||||
|                 if watch_title and watch_title.translate(str.maketrans('', '', "\r\n ")) == current_system_title: | ||||
|                 if watch_title and watch_title.translate(TRANSLATE_WHITESPACE_TABLE) == current_system_title: | ||||
|                     # Looks the same as the default one, so unset it | ||||
|                     watch['notification_title'] = None | ||||
|             except Exception as e: | ||||
|   | ||||
| @@ -11,8 +11,11 @@ | ||||
|     class="notification-urls" ) | ||||
|                             }} | ||||
|                             <div class="pure-form-message-inline"> | ||||
|                               <ul> | ||||
|                                 <li>Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.</li> | ||||
|                                 <p> | ||||
|                                 <strong>Tip:</strong> Use <a target=_new href="https://github.com/caronc/apprise">AppRise Notification URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.<br> | ||||
| </p> | ||||
|                                 <div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div> | ||||
|                                 <ul style="display: none" id="advanced-help-notifications"> | ||||
|                                 <li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_discord">discord://</a></code> (or <code>https://discord.com/api/webhooks...</code>)) only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li> | ||||
|                                 <li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> bots can't send messages to other bots, so you should specify chat ID of non-bot user.</li> | ||||
|                                 <li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> only supports very limited HTML and can fail when extra tags are sent, <a href="https://core.telegram.org/bots/api#html-style">read more here</a> (or use plaintext/markdown format)</li> | ||||
| @@ -40,7 +43,7 @@ | ||||
|  | ||||
|                             </div> | ||||
|                             <div class="pure-controls"> | ||||
|                                 <div id="notification-token-toggle" class="pure-button button-tag button-xsmall">Show token/placeholders</div> | ||||
|                                 <div data-target="#notification-tokens-info" class="toggle-show pure-button button-tag button-xsmall">Show token/placeholders</div> | ||||
|                             </div> | ||||
|                             <div class="pure-controls" style="display: none;" id="notification-tokens-info"> | ||||
|                                 <table class="pure-table" id="token-table"> | ||||
|   | ||||
| @@ -33,9 +33,11 @@ | ||||
|     <script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script> | ||||
|   </head> | ||||
|  | ||||
|   <body> | ||||
|   <body class=""> | ||||
|     <div class="header"> | ||||
|       <div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed" id="nav-menu"> | ||||
|     <div class="pure-menu-fixed" style="width: 100%;"> | ||||
|       <div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu"> | ||||
|  | ||||
|         {% if has_password and not current_user.is_authenticated %} | ||||
|           <a class="pure-menu-heading" href="https://changedetection.io" rel="noopener"> | ||||
|             <strong>Change</strong>Detection.io</a> | ||||
| @@ -68,7 +70,7 @@ | ||||
|                 <a href="{{ url_for('import_page')}}" class="pure-menu-link">IMPORT</a> | ||||
|               </li> | ||||
|               <li class="pure-menu-item"> | ||||
|                 <a href="{{ url_for('get_backup')}}" class="pure-menu-link">BACKUP</a> | ||||
|                 <a href="{{ url_for('backups.index')}}" class="pure-menu-link">BACKUPS</a> | ||||
|               </li> | ||||
|             {% else %} | ||||
|               <li class="pure-menu-item"> | ||||
| @@ -129,7 +131,12 @@ | ||||
|           </li> | ||||
|         </ul> | ||||
|       </div> | ||||
|       <div id="pure-menu-horizontal-spinner"></div> | ||||
|       </div> | ||||
|  | ||||
|     </div> | ||||
|  | ||||
|  | ||||
|     {% if hosted_sticky %} | ||||
|       <div class="sticky-tab" id="hosted-sticky"> | ||||
|         <a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a> | ||||
|   | ||||
| @@ -14,7 +14,7 @@ | ||||
|  | ||||
| <div id="settings"> | ||||
|     <form class="pure-form " action="" method="GET" id="diff-form"> | ||||
|         <fieldset> | ||||
|         <fieldset class="diff-fieldset"> | ||||
|             {% if versions|length >= 1 %} | ||||
|                 <strong>Compare</strong> | ||||
|                 <del class="change"><span>from</span></del> | ||||
| @@ -33,7 +33,7 @@ | ||||
|                         </option> | ||||
|                     {% endfor %} | ||||
|                 </select> | ||||
|                 <button type="submit" class="pure-button pure-button-primary">Go</button> | ||||
|                 <button type="submit" class="pure-button pure-button-primary reset-margin">Go</button> | ||||
|             {% endif %} | ||||
|         </fieldset> | ||||
|         <fieldset> | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
| {% from '_common_fields.html' import render_common_settings_form %} | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script> | ||||
| <script> | ||||
|     const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}'); | ||||
|     const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}'); | ||||
| @@ -23,9 +24,8 @@ | ||||
|     const watch_visual_selector_data_url="{{url_for('static_content', group='visual_selector_data', filename=uuid)}}"; | ||||
|     const default_system_fetch_backend="{{ settings_application['fetch_backend'] }}"; | ||||
| </script> | ||||
|  | ||||
| <script src="{{url_for('static_content', group='js', filename='plugins.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='limit.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='visual-selector.js')}}" defer></script> | ||||
| {% if playwright_enabled %} | ||||
| @@ -49,7 +49,7 @@ | ||||
|             {% endif %} | ||||
|             {% if watch['processor'] == 'text_json_diff' %} | ||||
|             <li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li> | ||||
|             <li class="tab"><a href="#filters-and-triggers">Filters & Triggers</a></li> | ||||
|             <li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters & Triggers</a></li> | ||||
|             {% endif %} | ||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||
|             <li class="tab"><a href="#stats">Stats</a></li> | ||||
| @@ -65,8 +65,8 @@ | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }} | ||||
|                         <span class="pure-form-message-inline">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></span><br> | ||||
|                         <span class="pure-form-message-inline">You can use variables in the URL, perfect for inserting the current date and other logic, <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a></span><br> | ||||
|                         <div class="pure-form-message">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></div> | ||||
|                         <div class="pure-form-message">Variables are supported in the URL (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a>).</div> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group inline-radio"> | ||||
|                         {{ render_field(form.processor) }} | ||||
| @@ -149,21 +149,24 @@ | ||||
|                             {{ render_field(form.method) }} | ||||
|                         </div> | ||||
|                         <div id="request-body"> | ||||
|                                             {{ render_field(form.body, rows=5, placeholder="Example | ||||
|                                             {{ render_field(form.body, rows=7, placeholder="Example | ||||
| { | ||||
|    \"name\":\"John\", | ||||
|    \"age\":30, | ||||
|    \"car\":null | ||||
|    \"car\":null, | ||||
|    \"year\":{% now 'Europe/Berlin', '%Y' %} | ||||
| }") }} | ||||
|                         </div> | ||||
|                         <div class="pure-form-message">Variables are supported in the request body (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a>).</div> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|             <!-- hmm --> | ||||
|                 <div class="pure-control-group advanced-options"  style="display: none;"> | ||||
|                     {{ render_field(form.headers, rows=5, placeholder="Example | ||||
|                     {{ render_field(form.headers, rows=7, placeholder="Example | ||||
| Cookie: foobar | ||||
| User-Agent: wonderbra 1.0") }} | ||||
|  | ||||
| User-Agent: wonderbra 1.0 | ||||
| Math: {{ 1 + 1 }}") }} | ||||
|                         <div class="pure-form-message">Variables are supported in the request header values (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a>).</div> | ||||
|                         <div class="pure-form-message-inline"> | ||||
|                             {% if has_extra_headers_file %} | ||||
|                                 <strong>Alert! Extra headers file found and will be added to this watch!</strong> | ||||
| @@ -199,7 +202,7 @@ User-Agent: wonderbra 1.0") }} | ||||
|                         <div id="loading-status-text" style="display: none;">Please wait, first browser step can take a little time to load..<div class="spinner"></div></div> | ||||
|                         <div class="flex-wrapper" > | ||||
|  | ||||
|                             <div id="browser-steps-ui" class="noselect"  style="width: 100%; background-color: #eee; border-radius: 5px;"> | ||||
|                             <div id="browser-steps-ui" class="noselect"> | ||||
|  | ||||
|                                 <div class="noselect"  id="browsersteps-selector-wrapper" style="width: 100%"> | ||||
|                                     <span class="loader" > | ||||
| @@ -214,7 +217,7 @@ User-Agent: wonderbra 1.0") }} | ||||
|                                     <canvas  class="noselect" id="browsersteps-selector-canvas" style="max-width: 100%; width: 100%;"></canvas> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                             <div id="browser-steps-fieldlist" style="padding-left: 1em;  width: 350px; font-size: 80%;" > | ||||
|                             <div id="browser-steps-fieldlist" > | ||||
|                                 <span id="browser-seconds-remaining">Loading</span> <span style="font-size: 80%;"> (<a target=_new href="https://github.com/dgtlmoon/changedetection.io/pull/478/files#diff-1a79d924d1840c485238e66772391268a89c95b781d69091384cf1ea1ac146c9R4">?</a>) </span> | ||||
|                                 {{ render_field(form.browser_steps) }} | ||||
|                             </div> | ||||
| @@ -253,7 +256,10 @@ User-Agent: wonderbra 1.0") }} | ||||
|  | ||||
|             {% if watch['processor'] == 'text_json_diff' %} | ||||
|             <div class="tab-pane-inner" id="filters-and-triggers"> | ||||
|                     <div class="pure-control-group"> | ||||
|                 <span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">Activate preview</span> | ||||
|               <div> | ||||
|               <div id="edit-text-filter"> | ||||
|                     <div class="pure-control-group" id="pro-tips"> | ||||
|                             <strong>Pro-tips:</strong><br> | ||||
|                             <ul> | ||||
|                                 <li> | ||||
| @@ -275,9 +281,9 @@ xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                         {% if '/text()' in  field %} | ||||
|                           <span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br> | ||||
|                         {% endif %} | ||||
|                         <span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br> | ||||
|  | ||||
|                     <ul> | ||||
|                         <span class="pure-form-message-inline">One CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br> | ||||
| <p><div data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div><br></p> | ||||
|                     <ul id="advanced-help-selectors" style="display: none;"> | ||||
|                         <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li> | ||||
|                         <li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed). | ||||
|                             <ul> | ||||
| @@ -297,21 +303,25 @@ xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                                 <li>To use XPath1.0: Prefix with <code>xpath1:</code></li> | ||||
|                             </ul> | ||||
|                             </li> | ||||
|                     </ul> | ||||
|                     Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a | ||||
|                     <li> | ||||
|                         Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a | ||||
|                                 href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br> | ||||
|                     </li> | ||||
|                     </ul> | ||||
|  | ||||
|                 </span> | ||||
|                     </div> | ||||
|                 <fieldset class="pure-control-group"> | ||||
|                     {{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header | ||||
| footer | ||||
| nav | ||||
| .stockticker") }} | ||||
| .stockticker | ||||
| //*[contains(text(), 'Advertisement')]") }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                           <li> Remove HTML element(s) by CSS selector before text conversion. </li> | ||||
|                           <li> Don't paste HTML here, use only CSS selectors </li> | ||||
|                           <li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li> | ||||
|                           <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> | ||||
| @@ -322,18 +332,25 @@ nav | ||||
|                         {{ render_checkbox_field(form.filter_text_added) }} | ||||
|                         {{ render_checkbox_field(form.filter_text_replaced) }} | ||||
|                         {{ render_checkbox_field(form.filter_text_removed) }} | ||||
|                     <span class="pure-form-message-inline">Note: Depending on the length and similarity of the text on each line, the algorithm may consider an <strong>addition</strong> instead of <strong>replacement</strong> for example.</span> | ||||
|                     <span class="pure-form-message-inline">So it's always better to select <strong>Added</strong>+<strong>Replaced</strong> when you're interested in new content.</span><br> | ||||
|                     <span class="pure-form-message-inline">When content is merely moved in a list, it will also trigger an <strong>addition</strong>, consider enabling <code><strong>Only trigger when unique lines appear</strong></code></span> | ||||
|                     <span class="pure-form-message-inline">Note: Depending on the length and similarity of the text on each line, the algorithm may consider an <strong>addition</strong> instead of <strong>replacement</strong> for example.</span><br> | ||||
|                     <span class="pure-form-message-inline"> So it's always better to select <strong>Added</strong>+<strong>Replaced</strong> when you're interested in new content.</span><br> | ||||
|                     <span class="pure-form-message-inline"> When content is merely moved in a list, it will also trigger an <strong>addition</strong>, consider enabling <code><strong>Only trigger when unique lines appear</strong></code></span> | ||||
|                 </fieldset> | ||||
|                 <fieldset class="pure-control-group"> | ||||
|                     {{ render_checkbox_field(form.check_unique_lines) }} | ||||
|                     <span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span> | ||||
|                 </fieldset> | ||||
|                 <fieldset class="pure-control-group"> | ||||
|                     {{ render_checkbox_field(form.remove_duplicate_lines) }} | ||||
|                     <span class="pure-form-message-inline">Remove duplicate lines of text</span> | ||||
|                 </fieldset> | ||||
|  | ||||
|                 <fieldset class="pure-control-group"> | ||||
|                     {{ render_checkbox_field(form.sort_text_alphabetically) }} | ||||
|                     <span class="pure-form-message-inline">Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below.</span> | ||||
|                 </fieldset> | ||||
|                 <fieldset class="pure-control-group"> | ||||
|                     {{ render_checkbox_field(form.check_unique_lines) }} | ||||
|                     <span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span> | ||||
|                     {{ render_checkbox_field(form.trim_text_whitespace) }} | ||||
|                     <span class="pure-form-message-inline">Remove any whitespace before and after each line of text</span> | ||||
|                 </fieldset> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
| @@ -356,10 +373,10 @@ nav | ||||
| ") }} | ||||
|                     <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>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> | ||||
|                             <li>Use the preview/show current tab to see ignores</li> | ||||
|                         </ul> | ||||
|                 </span> | ||||
|  | ||||
| @@ -383,7 +400,9 @@ Unavailable") }} | ||||
|                 </fieldset> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.extract_text, rows=5, placeholder="\d+ online") }} | ||||
|                         {{ render_field(form.extract_text, rows=5, placeholder="/.+?\d+ comments.+?/ | ||||
|  or | ||||
| keyword") }} | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                     <ul> | ||||
|                         <li>Extracts text in the final output (line by line) after other filters using regular expressions or string match; | ||||
| @@ -403,7 +422,27 @@ Unavailable") }} | ||||
|                 </fieldset> | ||||
|                 </div> | ||||
|             </div> | ||||
|             {% endif %} | ||||
|               <div id="text-preview" style="display: none;" > | ||||
|                     <script> | ||||
|                         const preview_text_edit_filters_url="{{url_for('watch_get_preview_rendered', uuid=uuid)}}"; | ||||
|                     </script> | ||||
|                     <br> | ||||
|                     {#<div id="text-preview-controls"><span id="text-preview-refresh" class="pure-button button-xsmall">Refresh</span></div>#} | ||||
|                     <div class="minitabs-wrapper"> | ||||
|                       <div class="minitabs-content"> | ||||
|                           <div id="text-preview-inner" class="monospace-preview"> | ||||
|                               <p>Loading...</p> | ||||
|                           </div> | ||||
|                           <div id="text-preview-before-inner" style="display: none;" class="monospace-preview"> | ||||
|                               <p>Loading...</p> | ||||
|                           </div> | ||||
|                       </div> | ||||
|                     </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         {% endif %} | ||||
|         {# rendered sub Template #} | ||||
|         {% if extra_form_content %} | ||||
|             <div class="tab-pane-inner" id="extras_tab"> | ||||
|   | ||||
| @@ -3,11 +3,13 @@ | ||||
| {% block content %} | ||||
|     <script> | ||||
|         const screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid)}}"; | ||||
|         const triggered_line_numbers = {{ triggered_line_numbers|tojson }}; | ||||
|         {% if last_error_screenshot %} | ||||
|             const error_screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||
|         {% endif %} | ||||
|         const highlight_submit_ignore_url = "{{url_for('highlight_submit_ignore_url', uuid=uuid)}}"; | ||||
|     </script> | ||||
|     <script src="{{url_for('static_content', group='js', filename='plugins.js')}}"></script> | ||||
|     <script src="{{ url_for('static_content', group='js', filename='diff-overview.js') }}" defer></script> | ||||
|     <script src="{{ url_for('static_content', group='js', filename='preview.js') }}" defer></script> | ||||
|     <script src="{{ url_for('static_content', group='js', filename='tabs.js') }}" defer></script> | ||||
| @@ -67,16 +69,15 @@ | ||||
|  | ||||
|         <div class="tab-pane-inner" id="text"> | ||||
|             <div class="snapshot-age">{{ current_version|format_timestamp_timeago }}</div> | ||||
|             <span class="ignored">Grey lines are ignored</span> <span class="triggered">Blue lines are triggers</span> | ||||
|             <span class="tip"><strong>Pro-tip</strong>: Highlight text to add to ignore filters</span> | ||||
|  | ||||
|             <table> | ||||
|                 <tbody> | ||||
|                 <tr> | ||||
|                     <td id="diff-col" class="highlightable-filter"> | ||||
|                         {% for row in content %} | ||||
|                             <div class="{{ row.classes }}">{{ row.line }}</div> | ||||
|                         {% endfor %} | ||||
|                         <pre style="border-left: 2px solid #ddd;"> | ||||
| {{ content }} | ||||
|                         </pre> | ||||
|                     </td> | ||||
|                 </tr> | ||||
|                 </tbody> | ||||
|   | ||||
| @@ -76,7 +76,7 @@ | ||||
|                     </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 page contains HTML, but no renderable text appears (empty page), is this considered a change?</span> | ||||
|                         <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"> | ||||
| @@ -155,11 +155,13 @@ | ||||
|                       {{ render_field(form.application.form.global_subtractive_selectors, rows=5, placeholder="header | ||||
| footer | ||||
| nav | ||||
| .stockticker") }} | ||||
| .stockticker | ||||
| //*[contains(text(), 'Advertisement')]") }} | ||||
|                       <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                           <li> Remove HTML element(s) by CSS selector before text conversion. </li> | ||||
|                           <li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li> | ||||
|                           <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> | ||||
| @@ -170,11 +172,11 @@ nav | ||||
|                     <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> | ||||
|                             <li>Use the preview/show current tab to see ignores</li> | ||||
|                         </ul> | ||||
|                      </span> | ||||
|                     </fieldset> | ||||
| @@ -274,7 +276,7 @@ nav | ||||
|                 <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-cancel">Clear Snapshot History</a> | ||||
|                     <a href="{{url_for('clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </form> | ||||
|   | ||||
| @@ -78,8 +78,8 @@ | ||||
|              {% if any_has_restock_price_processor %} | ||||
|                 <th>Restock & Price</th> | ||||
|              {% endif %} | ||||
|                 <th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}">Last Checked <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Checked <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Changed <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th class="empty-cell"></th> | ||||
|             </tr> | ||||
|             </thead> | ||||
| @@ -191,9 +191,9 @@ | ||||
|                     {% if watch.history_n >= 2 %} | ||||
|  | ||||
|                         {%  if is_unviewed %} | ||||
|                            <a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_next_snapshot_key_to_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">Diff</a> | ||||
|                            <a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_next_snapshot_key_to_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a> | ||||
|                         {% else %} | ||||
|                            <a href="{{ url_for('diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">Diff</a> | ||||
|                            <a href="{{ url_for('diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a> | ||||
|                         {% endif %} | ||||
|  | ||||
|                     {% else %} | ||||
|   | ||||
							
								
								
									
										6
									
								
								changedetectionio/tests/itemprop_test_examples/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								changedetectionio/tests/itemprop_test_examples/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| # A list of real world examples! | ||||
|  | ||||
| Always the price should be 666.66 for our tests | ||||
|  | ||||
| see test_restock_itemprop.py::test_special_prop_examples | ||||
|  | ||||
							
								
								
									
										25
									
								
								changedetectionio/tests/itemprop_test_examples/a.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								changedetectionio/tests/itemprop_test_examples/a.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| <div class="PriceSection PriceSection_PriceSection__Vx1_Q PriceSection_variantHuge__P9qxg PdpPriceSection" | ||||
|      data-testid="price-section" | ||||
|      data-optly-product-tile-price-section="true"><span | ||||
|         class="PriceRange ProductPrice variant-huge" itemprop="offers" | ||||
|         itemscope="" itemtype="http://schema.org/Offer"><div | ||||
|         class="VisuallyHidden_VisuallyHidden__VBD83">$155.55</div><span | ||||
|         aria-hidden="true" class="Price variant-huge" data-testid="price" | ||||
|         itemprop="price"><sup class="sup" data-testid="price-symbol" | ||||
|                               itemprop="priceCurrency" content="AUD">$</sup><span | ||||
|         class="dollars" data-testid="price-value" itemprop="price" | ||||
|         content="155.55">155.55</span><span class="extras"><span class="sup" | ||||
|                                                               data-testid="price-sup"></span></span></span></span> | ||||
| </div> | ||||
|  | ||||
| <script type="application/ld+json">{ | ||||
|                                 "@type": "Product", | ||||
|                                 "@context": "https://schema.org", | ||||
|                                 "name": "test", | ||||
|                                 "description": "test", | ||||
|                                 "offers": { | ||||
|                                     "@type": "Offer", | ||||
|                                     "priceCurrency": "AUD", | ||||
|                                     "price": 155.55 | ||||
|                                 }, | ||||
|                             }</script> | ||||
| @@ -16,4 +16,4 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     ) | ||||
|  | ||||
|     assert b"1 Imported" in res.data | ||||
|     time.sleep(3) | ||||
|     wait_for_all_checks(client) | ||||
|   | ||||
| @@ -44,7 +44,7 @@ def test_select_custom(client, live_server, measure_memory_usage): | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     # We should see something via proxy | ||||
|     assert b'<div class=""> - 0.' in res.data | ||||
|     assert b' - 0.' in res.data | ||||
|  | ||||
|     # | ||||
|     # Now we should see the request in the container logs for "squid-squid-custom" because it will be the only default | ||||
|   | ||||
| @@ -1,12 +1,27 @@ | ||||
| #!/usr/bin/env python3 | ||||
| import json | ||||
| import os | ||||
| import time | ||||
| from flask import url_for | ||||
| from changedetectionio.tests.util import live_server_setup, wait_for_all_checks | ||||
| from changedetectionio.tests.util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
|  | ||||
|  | ||||
| def set_response(): | ||||
|     import time | ||||
|     data = f"""<html> | ||||
|        <body> | ||||
|      <h1>Awesome, you made it</h1> | ||||
|      yeah the socks request worked | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(data) | ||||
|     time.sleep(1) | ||||
|  | ||||
| def test_socks5(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     set_response() | ||||
|  | ||||
|     # Setup a proxy | ||||
|     res = client.post( | ||||
| @@ -24,7 +39,10 @@ def test_socks5(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     assert b"Settings updated." in res.data | ||||
|  | ||||
|     test_url = "https://changedetection.io/CHANGELOG.txt?socks-test-tag=" + os.getenv('SOCKSTEST', '') | ||||
|     # Because the socks server should connect back to us | ||||
|     test_url = url_for('test_endpoint', _external=True) + f"?socks-test-tag={os.getenv('SOCKSTEST', '')}" | ||||
|     test_url = test_url.replace('localhost.localdomain', 'cdio') | ||||
|     test_url = test_url.replace('localhost', 'cdio') | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
| @@ -60,4 +78,25 @@ def test_socks5(client, live_server, measure_memory_usage): | ||||
|     ) | ||||
|  | ||||
|     # Should see the proper string | ||||
|     assert "+0200:".encode('utf-8') in res.data | ||||
|     assert "Awesome, you made it".encode('utf-8') in res.data | ||||
|  | ||||
|     # PROXY CHECKER WIDGET CHECK - this needs more checking | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("check_proxies.start_check", uuid=uuid), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     # It's probably already finished super fast :( | ||||
|     #assert b"RUNNING" in res.data | ||||
|      | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get( | ||||
|         url_for("check_proxies.get_recheck_status", uuid=uuid), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"OK" in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|   | ||||
| @@ -1,16 +1,32 @@ | ||||
| #!/usr/bin/env python3 | ||||
| import os | ||||
| import time | ||||
| from flask import url_for | ||||
| from changedetectionio.tests.util import live_server_setup, wait_for_all_checks | ||||
|  | ||||
|  | ||||
| def set_response(): | ||||
|     import time | ||||
|     data = f"""<html> | ||||
|        <body> | ||||
|      <h1>Awesome, you made it</h1> | ||||
|      yeah the socks request worked | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(data) | ||||
|     time.sleep(1) | ||||
|  | ||||
| # should be proxies.json mounted from run_proxy_tests.sh already | ||||
| # -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json | ||||
| def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     test_url = "https://changedetection.io/CHANGELOG.txt?socks-test-tag=" + os.getenv('SOCKSTEST', '') | ||||
|     set_response() | ||||
|     # Because the socks server should connect back to us | ||||
|     test_url = url_for('test_endpoint', _external=True) + f"?socks-test-tag={os.getenv('SOCKSTEST', '')}" | ||||
|     test_url = test_url.replace('localhost.localdomain', 'cdio') | ||||
|     test_url = test_url.replace('localhost', 'cdio') | ||||
|  | ||||
|     res = client.get(url_for("settings_page")) | ||||
|     assert b'name="requests-proxy" type="radio" value="socks5proxy"' in res.data | ||||
| @@ -49,4 +65,4 @@ def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage) | ||||
|     ) | ||||
|  | ||||
|     # Should see the proper string | ||||
|     assert "+0200:".encode('utf-8') in res.data | ||||
|     assert "Awesome, you made it".encode('utf-8') in res.data | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| import os | ||||
| import time | ||||
| from flask import url_for | ||||
| from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
| from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client, wait_for_notification_endpoint_output | ||||
| from changedetectionio.notification import ( | ||||
|     default_notification_body, | ||||
|     default_notification_format, | ||||
| @@ -94,7 +94,7 @@ def test_restock_detection(client, live_server, measure_memory_usage): | ||||
|     assert b'not-in-stock' not in res.data | ||||
|  | ||||
|     # We should have a notification | ||||
|     time.sleep(2) | ||||
|     wait_for_notification_endpoint_output() | ||||
|     assert os.path.isfile("test-datastore/notification.txt"), "Notification received" | ||||
|     os.unlink("test-datastore/notification.txt") | ||||
|  | ||||
| @@ -103,6 +103,7 @@ def test_restock_detection(client, live_server, measure_memory_usage): | ||||
|     set_original_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     time.sleep(5) | ||||
|     assert not os.path.isfile("test-datastore/notification.txt"), "No notification should have fired when it went OUT OF STOCK by default" | ||||
|  | ||||
|     # BUT we should see that it correctly shows "not in stock" | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| import os.path | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
| from .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output | ||||
| from changedetectionio import html_tools | ||||
|  | ||||
|  | ||||
| @@ -39,9 +39,8 @@ def test_setup(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def test_check_removed_line_contains_trigger(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|     set_original() | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
| @@ -78,6 +77,8 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory | ||||
|  | ||||
|     # The trigger line is REMOVED,  this should trigger | ||||
|     set_original(excluding='The golden line') | ||||
|  | ||||
|     # Check in the processor here what's going on, its triggering empty-reply and no change. | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
| @@ -112,7 +113,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", | ||||
|               "application-notification_body": 'triggered text was -{{triggered_text}}-', | ||||
|               "application-notification_body": 'triggered text was -{{triggered_text}}- 网站监测 内容更新了', | ||||
|               # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation | ||||
|               "application-notification_urls": test_notification_url, | ||||
|               "application-minutes_between_check": 180, | ||||
| @@ -153,6 +154,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa | ||||
|     # A line thats not the trigger should not trigger anything | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
| @@ -165,12 +167,12 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     # Takes a moment for apprise to fire | ||||
|     time.sleep(3) | ||||
|     wait_for_notification_endpoint_output() | ||||
|     assert os.path.isfile("test-datastore/notification.txt"), "Notification fired because I can see the output file" | ||||
|     with open("test-datastore/notification.txt", 'r') as f: | ||||
|         response= f.read() | ||||
|         assert '-Oh yes please-' in response | ||||
|  | ||||
|     with open("test-datastore/notification.txt", 'rb') as f: | ||||
|         response = f.read() | ||||
|         assert b'-Oh yes please-' in response | ||||
|         assert '网站监测 内容更新了'.encode('utf-8') in response | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -69,6 +69,12 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|  | ||||
|     # Check the 'get latest snapshot works' | ||||
|     res = client.get(url_for("watch_get_latest_html", uuid=uuid)) | ||||
|     assert b'which has this one new line' in res.data | ||||
|  | ||||
|     # Now something should be ready, indicated by having a 'unviewed' class | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
| @@ -86,7 +92,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     assert expected_url.encode('utf-8') in res.data | ||||
|  | ||||
|     # Following the 'diff' link, it should no longer display as 'unviewed' even after we recheck it a few times | ||||
|     res = client.get(url_for("diff_history_page", uuid="first")) | ||||
|     res = client.get(url_for("diff_history_page", uuid=uuid)) | ||||
|     assert b'selected=""' in res.data, "Confirm diff history page loaded" | ||||
|  | ||||
|     # Check the [preview] pulls the right one | ||||
| @@ -143,18 +149,12 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     # #2458 "clear history" should make the Watch object update its status correctly when the first snapshot lands again | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     client.get(url_for("clear_watch_history", uuid=uuid)) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'preview/' in res.data | ||||
|  | ||||
|  | ||||
|     # Check the 'get latest snapshot works' | ||||
|     res = client.get(url_for("watch_get_latest_html", uuid=uuid)) | ||||
|     assert b'<head><title>head title</title></head>' in res.data | ||||
|  | ||||
|     # | ||||
|     # Cleanup everything | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|   | ||||
| @@ -26,8 +26,24 @@ def test_backup(client, live_server, measure_memory_usage): | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Launch the thread in the background to create the backup | ||||
|     res = client.get( | ||||
|         url_for("get_backup"), | ||||
|         url_for("backups.request_backup"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     time.sleep(2) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("backups.index"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     # Can see the download link to the backup | ||||
|     assert b'<a href="/backups/download/changedetection-backup-20' in res.data | ||||
|     assert b'Remove backups' in res.data | ||||
|  | ||||
|     # Get the latest one | ||||
|     res = client.get( | ||||
|         url_for("backups.download_backup", filename="latest"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| @@ -44,3 +60,11 @@ def test_backup(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Should be two txt files in the archive (history and the snapshot) | ||||
|     assert len(newlist) == 2 | ||||
|  | ||||
|     # Get the latest one | ||||
|     res = client.get( | ||||
|         url_for("backups.remove_backups"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b'No backups found.' in res.data | ||||
| @@ -65,11 +65,8 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu | ||||
|     live_server_setup(live_server) | ||||
|     # Use a mix of case in ZzZ to prove it works case-insensitive. | ||||
|     ignore_text = "out of stoCk\r\nfoobar" | ||||
|  | ||||
|     set_original_ignore_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
| @@ -127,13 +124,24 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu | ||||
|     assert b'unviewed' not in res.data | ||||
|     assert b'/test-endpoint' in res.data | ||||
|  | ||||
|     # 2548 | ||||
|     # Going back to the ORIGINAL should NOT trigger a change | ||||
|     set_original_ignore_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     # Now we set a change where the text is gone, it should now trigger | ||||
|  | ||||
|     # Now we set a change where the text is gone AND its different content, it should now trigger | ||||
|     set_modified_response_minus_block_text() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -5,12 +5,41 @@ import time | ||||
| from flask import url_for | ||||
|  | ||||
| from ..html_tools import * | ||||
| from .util import live_server_setup | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
|  | ||||
|  | ||||
| def test_setup(live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def set_response_with_multiple_index(): | ||||
|     data= """<!DOCTYPE html> | ||||
| <html> | ||||
| <body> | ||||
|  | ||||
| <!-- NOTE!! CHROME WILL ADD TBODY HERE IF ITS NOT THERE!! --> | ||||
| <table style="width:100%"> | ||||
|   <tr> | ||||
|     <th>Person 1</th> | ||||
|     <th>Person 2</th> | ||||
|     <th>Person 3</th> | ||||
|   </tr> | ||||
|   <tr> | ||||
|     <td>Emil</td> | ||||
|     <td>Tobias</td> | ||||
|     <td>Linus</td> | ||||
|   </tr> | ||||
|   <tr> | ||||
|     <td>16</td> | ||||
|     <td>14</td> | ||||
|     <td>10</td> | ||||
|   </tr> | ||||
| </table> | ||||
| </body> | ||||
| </html> | ||||
| """ | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(data) | ||||
|  | ||||
|  | ||||
| def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
| @@ -87,6 +116,9 @@ def test_element_removal_output(): | ||||
|      Some initial text<br> | ||||
|      <p>across multiple lines</p> | ||||
|      <div id="changetext">Some text that changes</div> | ||||
|      <div>Some text should be matched by xPath // selector</div> | ||||
|      <div>Some text should be matched by xPath selector</div> | ||||
|      <div>Some text should be matched by xPath1 selector</div> | ||||
|      </body> | ||||
|     <footer> | ||||
|     <p>Footer</p> | ||||
| @@ -94,7 +126,16 @@ def test_element_removal_output(): | ||||
|      </html> | ||||
|     """ | ||||
|     html_blob = element_removal( | ||||
|         ["header", "footer", "nav", "#changetext"], html_content=content | ||||
|       [ | ||||
|         "header", | ||||
|         "footer", | ||||
|         "nav", | ||||
|         "#changetext", | ||||
|         "//*[contains(text(), 'xPath // selector')]", | ||||
|         "xpath://*[contains(text(), 'xPath selector')]", | ||||
|         "xpath1://*[contains(text(), 'xPath1 selector')]" | ||||
|       ], | ||||
|       html_content=content | ||||
|     ) | ||||
|     text = get_text(html_blob) | ||||
|     assert ( | ||||
| @@ -107,12 +148,10 @@ across multiple lines | ||||
|  | ||||
|  | ||||
| def test_element_removal_full(client, live_server, measure_memory_usage): | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     set_original_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for("test_endpoint", _external=True) | ||||
| @@ -120,7 +159,8 @@ def test_element_removal_full(client, live_server, measure_memory_usage): | ||||
|         url_for("import_page"), data={"urls": test_url}, follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     time.sleep(1) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Goto the edit page, add the filter data | ||||
|     # Not sure why \r needs to be added - absent of the #changetext this is not necessary | ||||
|     subtractive_selectors_data = "header\r\nfooter\r\nnav\r\n#changetext" | ||||
| @@ -136,6 +176,7 @@ def test_element_removal_full(client, live_server, measure_memory_usage): | ||||
|         follow_redirects=True, | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
| @@ -144,10 +185,10 @@ def test_element_removal_full(client, live_server, measure_memory_usage): | ||||
|     assert bytes(subtractive_selectors_data.encode("utf-8")) in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # so that we set the state to 'unviewed' after all the edits | ||||
|     client.get(url_for("diff_history_page", uuid="first")) | ||||
| @@ -156,11 +197,70 @@ def test_element_removal_full(client, live_server, measure_memory_usage): | ||||
|     set_modified_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # There should not be an unviewed change, as changes should be removed | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b"unviewed" not in res.data | ||||
|  | ||||
| # Re #2752 | ||||
| def test_element_removal_nth_offset_no_shift(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     set_response_with_multiple_index() | ||||
|     subtractive_selectors_data = [""" | ||||
| body > table > tr:nth-child(1) > th:nth-child(2) | ||||
| body > table >  tr:nth-child(2) > td:nth-child(2) | ||||
| body > table > tr:nth-child(3) > td:nth-child(2) | ||||
| body > table > tr:nth-child(1) > th:nth-child(3) | ||||
| body > table >  tr:nth-child(2) > td:nth-child(3) | ||||
| body > table > tr:nth-child(3) > td:nth-child(3)""", | ||||
| """//body/table/tr[1]/th[2] | ||||
| //body/table/tr[2]/td[2] | ||||
| //body/table/tr[3]/td[2] | ||||
| //body/table/tr[1]/th[3] | ||||
| //body/table/tr[2]/td[3] | ||||
| //body/table/tr[3]/td[3]"""] | ||||
|  | ||||
|     for selector_list in subtractive_selectors_data: | ||||
|  | ||||
|         res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|         assert b'Deleted' in res.data | ||||
|  | ||||
|         # Add our URL to the import page | ||||
|         test_url = url_for("test_endpoint", _external=True) | ||||
|         res = client.post( | ||||
|             url_for("import_page"), data={"urls": test_url}, follow_redirects=True | ||||
|         ) | ||||
|         assert b"1 Imported" in res.data | ||||
|         wait_for_all_checks(client) | ||||
|  | ||||
|         res = client.post( | ||||
|             url_for("edit_page", uuid="first"), | ||||
|             data={ | ||||
|                 "subtractive_selectors": selector_list, | ||||
|                 "url": test_url, | ||||
|                 "tags": "", | ||||
|                 "fetch_backend": "html_requests", | ||||
|             }, | ||||
|             follow_redirects=True, | ||||
|         ) | ||||
|         assert b"Updated watch." in res.data | ||||
|         wait_for_all_checks(client) | ||||
|  | ||||
|         res = client.get( | ||||
|             url_for("preview_page", uuid="first"), | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|  | ||||
|         assert b"Tobias" not in res.data | ||||
|         assert b"Linus" not in res.data | ||||
|         assert b"Person 2" not in res.data | ||||
|         assert b"Person 3" not in res.data | ||||
|         # First column should exist | ||||
|         assert b"Emil" in res.data | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
| from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
| import pytest | ||||
|  | ||||
|  | ||||
| @@ -38,6 +38,11 @@ def test_check_encoding_detection(client, live_server, measure_memory_usage): | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|  | ||||
|     # Content type recording worked | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid]['content-type'] == "text/html" | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|   | ||||
| @@ -71,7 +71,7 @@ def test_setup(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def test_check_filter_multiline(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|    # live_server_setup(live_server) | ||||
|     set_multiline_response() | ||||
|  | ||||
|     # Add our URL to the import page | ||||
| @@ -115,9 +115,9 @@ def test_check_filter_multiline(client, live_server, measure_memory_usage): | ||||
|     # Plaintext that doesnt look like a regex should match also | ||||
|     assert b'and this should be' in res.data | ||||
|  | ||||
|     assert b'<div class="">Something' in res.data | ||||
|     assert b'<div class="">across 6 billion multiple' in res.data | ||||
|     assert b'<div class="">lines' in res.data | ||||
|     assert b'Something' in res.data | ||||
|     assert b'across 6 billion multiple' in res.data | ||||
|     assert b'lines' in res.data | ||||
|  | ||||
|     # but the last one, which also says 'lines' shouldnt be here (non-greedy match checking) | ||||
|     assert b'aaand something lines' not in res.data | ||||
| @@ -183,20 +183,19 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     # Class will be blank for now because the frontend didnt apply the diff | ||||
|     assert b'<div class="">1000 online' in res.data | ||||
|     assert b'1000 online' in res.data | ||||
|  | ||||
|     # All regex matching should be here | ||||
|     assert b'<div class="">2000 online' in res.data | ||||
|     assert b'2000 online' in res.data | ||||
|  | ||||
|     # Both regexs should be here | ||||
|     assert b'<div class="">80 guests' in res.data | ||||
|     assert b'80 guests' in res.data | ||||
|  | ||||
|     # Regex with flag handling should be here | ||||
|     assert b'<div class="">SomeCase insensitive 3456' in res.data | ||||
|     assert b'SomeCase insensitive 3456' in res.data | ||||
|  | ||||
|     # Singular group from /somecase insensitive (345\d)/i | ||||
|     assert b'<div class="">3456' in res.data | ||||
|     assert b'3456' in res.data | ||||
|  | ||||
|     # Regex with multiline flag handling should be here | ||||
|  | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
| import os | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import set_original_response, live_server_setup | ||||
| from .util import set_original_response, live_server_setup, wait_for_notification_endpoint_output | ||||
| from changedetectionio.model import App | ||||
|  | ||||
|  | ||||
| @@ -102,14 +102,15 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     time.sleep(3) | ||||
|     wait_for_notification_endpoint_output() | ||||
|  | ||||
|     # Shouldn't exist, shouldn't have fired | ||||
|     assert not os.path.isfile("test-datastore/notification.txt") | ||||
|     # Now the filter should exist | ||||
|     set_response_with_filter() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(3) | ||||
|  | ||||
|     wait_for_notification_endpoint_output() | ||||
|  | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import os | ||||
| import time | ||||
| from loguru import logger | ||||
| from flask import url_for | ||||
| from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks | ||||
| from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks, \ | ||||
|     wait_for_notification_endpoint_output | ||||
| from changedetectionio.model import App | ||||
|  | ||||
|  | ||||
| @@ -26,6 +28,12 @@ def run_filter_test(client, live_server, content_filter): | ||||
|     # Response WITHOUT the filter ID element | ||||
|     set_original_response() | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     # cleanup for the next | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
| @@ -34,83 +42,92 @@ def run_filter_test(client, live_server, content_filter): | ||||
|     if os.path.isfile("test-datastore/notification.txt"): | ||||
|         os.unlink("test-datastore/notification.txt") | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": ''}, | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"Watch added" in res.data | ||||
|  | ||||
|     # Give the thread time to pick up the first version | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     url = url_for('test_notification_endpoint', _external=True) | ||||
|     notification_url = url.replace('http', 'json') | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|  | ||||
|     print(">>>> Notification URL: " + notification_url) | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure" | ||||
|  | ||||
|     # Just a regular notification setting, this will be used by the special 'filter not found' notification | ||||
|     notification_form_data = {"notification_urls": notification_url, | ||||
|                               "notification_title": "New ChangeDetection.io Notification - {{watch_url}}", | ||||
|                               "notification_body": "BASE URL: {{base_url}}\n" | ||||
|                                                    "Watch URL: {{watch_url}}\n" | ||||
|                                                    "Watch UUID: {{watch_uuid}}\n" | ||||
|                                                    "Watch title: {{watch_title}}\n" | ||||
|                                                    "Watch tag: {{watch_tag}}\n" | ||||
|                                                    "Preview: {{preview_url}}\n" | ||||
|                                                    "Diff URL: {{diff_url}}\n" | ||||
|                                                    "Snapshot: {{current_snapshot}}\n" | ||||
|                                                    "Diff: {{diff}}\n" | ||||
|                                                    "Diff Full: {{diff_full}}\n" | ||||
|                                                    "Diff as Patch: {{diff_patch}}\n" | ||||
|                                                    ":-)", | ||||
|                               "notification_format": "Text"} | ||||
|     watch_data = {"notification_urls": notification_url, | ||||
|                   "notification_title": "New ChangeDetection.io Notification - {{watch_url}}", | ||||
|                   "notification_body": "BASE URL: {{base_url}}\n" | ||||
|                                        "Watch URL: {{watch_url}}\n" | ||||
|                                        "Watch UUID: {{watch_uuid}}\n" | ||||
|                                        "Watch title: {{watch_title}}\n" | ||||
|                                        "Watch tag: {{watch_tag}}\n" | ||||
|                                        "Preview: {{preview_url}}\n" | ||||
|                                        "Diff URL: {{diff_url}}\n" | ||||
|                                        "Snapshot: {{current_snapshot}}\n" | ||||
|                                        "Diff: {{diff}}\n" | ||||
|                                        "Diff Full: {{diff_full}}\n" | ||||
|                                        "Diff as Patch: {{diff_patch}}\n" | ||||
|                                        ":-)", | ||||
|                   "notification_format": "Text", | ||||
|                   "fetch_backend": "html_requests", | ||||
|                   "filter_failure_notification_send": 'y', | ||||
|                   "headers": "", | ||||
|                   "tags": "my tag", | ||||
|                   "title": "my title 123", | ||||
|                   "time_between_check-hours": 5,  # So that the queue runner doesnt also put it in | ||||
|                   "url": test_url, | ||||
|                   } | ||||
|  | ||||
|     notification_form_data.update({ | ||||
|         "url": test_url, | ||||
|         "tags": "my tag", | ||||
|         "title": "my title 123", | ||||
|         "headers": "", | ||||
|         "filter_failure_notification_send": 'y', | ||||
|         "include_filters": content_filter, | ||||
|         "fetch_backend": "html_requests"}) | ||||
|  | ||||
|     # A POST here will also reset the filter failure counter (filter_failure_notification_threshold_attempts) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data=notification_form_data, | ||||
|         url_for("edit_page", uuid=uuid), | ||||
|         data=watch_data, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"Updated watch." in res.data | ||||
|     wait_for_all_checks(client) | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure" | ||||
|  | ||||
|     # Now the notification should not exist, because we didnt reach the threshold | ||||
|     # Now add a filter, because recheck hours == 5, ONLY pressing of the [edit] or [recheck all] should trigger | ||||
|     watch_data['include_filters'] = content_filter | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid), | ||||
|         data=watch_data, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     # It should have checked once so far and given this error (because we hit SAVE) | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     assert not os.path.isfile("test-datastore/notification.txt") | ||||
|  | ||||
|     # Hitting [save] would have triggered a recheck, and we have a filter, so this would be ONE failure | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 1, "Should have been checked once" | ||||
|  | ||||
|     # recheck it up to just before the threshold, including the fact that in the previous POST it would have rechecked (and incremented) | ||||
|     for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT-2): | ||||
|     # Add 4 more checks | ||||
|     checked = 0 | ||||
|     ATTEMPT_THRESHOLD_SETTING = live_server.app.config['DATASTORE'].data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0) | ||||
|     for i in range(0, ATTEMPT_THRESHOLD_SETTING - 2): | ||||
|         checked += 1 | ||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|         wait_for_all_checks(client) | ||||
|         time.sleep(2) # delay for apprise to fire | ||||
|         assert not os.path.isfile("test-datastore/notification.txt"), f"test-datastore/notification.txt should not exist - Attempt {i} when threshold is {App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT}" | ||||
|  | ||||
|     # We should see something in the frontend | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'Warning, no filters were found' in res.data | ||||
|         res = client.get(url_for("index")) | ||||
|         assert b'Warning, no filters were found' in res.data | ||||
|         assert not os.path.isfile("test-datastore/notification.txt") | ||||
|         time.sleep(1) | ||||
|          | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 5 | ||||
|  | ||||
|     time.sleep(2) | ||||
|     # One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     time.sleep(2)  # delay for apprise to fire | ||||
|     wait_for_notification_endpoint_output() | ||||
|  | ||||
|     # Now it should exist and contain our "filter not found" alert | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
|  | ||||
|     with open("test-datastore/notification.txt", 'r') as f: | ||||
|         notification = f.read() | ||||
|  | ||||
| @@ -123,10 +140,11 @@ def run_filter_test(client, live_server, content_filter): | ||||
|     set_response_with_filter() | ||||
|  | ||||
|     # Try several times, it should NOT have 'filter not found' | ||||
|     for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT): | ||||
|     for i in range(0, ATTEMPT_THRESHOLD_SETTING + 2): | ||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|         wait_for_all_checks(client) | ||||
|  | ||||
|     wait_for_notification_endpoint_output() | ||||
|     # It should have sent a notification, but.. | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
|     # but it should not contain the info about a failed filter (because there was none in this case) | ||||
| @@ -135,9 +153,6 @@ def run_filter_test(client, live_server, content_filter): | ||||
|     assert not 'CSS/xPath filter was not present in the page' in notification | ||||
|  | ||||
|     # Re #1247 - All tokens got replaced correctly in the notification | ||||
|     res = client.get(url_for("index")) | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     # UUID is correct, but notification contains tag uuid as UUIID wtf | ||||
|     assert uuid in notification | ||||
|  | ||||
|     # cleanup for the next | ||||
| @@ -152,9 +167,11 @@ def test_setup(live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage): | ||||
| #    live_server_setup(live_server) | ||||
|     run_filter_test(client, live_server,'#nope-doesnt-exist') | ||||
|  | ||||
| def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage): | ||||
| #    live_server_setup(live_server) | ||||
|     run_filter_test(client, live_server, '//*[@id="nope-doesnt-exist"]') | ||||
|  | ||||
| # Test that notification is never sent | ||||
|   | ||||
| @@ -23,7 +23,7 @@ def set_original_ignore_response(): | ||||
|         f.write(test_return_data) | ||||
| 
 | ||||
| 
 | ||||
| def test_highlight_ignore(client, live_server, measure_memory_usage): | ||||
| def test_ignore(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     set_original_ignore_response() | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
| @@ -51,9 +51,9 @@ def test_highlight_ignore(client, live_server, measure_memory_usage): | ||||
|     # Should return a link | ||||
|     assert b'href' in res.data | ||||
| 
 | ||||
|     # And it should register in the preview page | ||||
|     # It should not be in the preview anymore | ||||
|     res = client.get(url_for("preview_page", uuid=uuid)) | ||||
|     assert b'<div class="ignored">oh yeah 456' in res.data | ||||
|     assert b'<div class="ignored">oh yeah 456' not in res.data | ||||
| 
 | ||||
|     # Should be in base.html | ||||
|     assert b'csrftoken' in res.data | ||||
| @@ -33,13 +33,17 @@ def test_strip_regex_text_func(): | ||||
|  | ||||
|     stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines) | ||||
|  | ||||
|     assert b"but 1 lines" in stripped_content | ||||
|     assert b"igNORe-cAse text" not in stripped_content | ||||
|     assert b"but 1234 lines" not in stripped_content | ||||
|     assert b"really" not in stripped_content | ||||
|     assert b"not this" not in stripped_content | ||||
|     assert "but 1 lines" in stripped_content | ||||
|     assert "igNORe-cAse text" not in stripped_content | ||||
|     assert "but 1234 lines" not in stripped_content | ||||
|     assert "really" not in stripped_content | ||||
|     assert "not this" not in stripped_content | ||||
|  | ||||
|     # Check line number reporting | ||||
|     stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines, mode="line numbers") | ||||
|     assert stripped_content == [2, 5, 6, 7, 8, 10] | ||||
|  | ||||
|     # Check that linefeeds are preserved when there are is no matching ignores | ||||
|     content = "some text\n\nand other text\n" | ||||
|     stripped_content = html_tools.strip_ignore_text(content, ignore_lines) | ||||
|     assert content == stripped_content | ||||
|   | ||||
| @@ -22,10 +22,15 @@ def test_strip_text_func(): | ||||
|     ignore_lines = ["sometimes"] | ||||
|  | ||||
|     stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines) | ||||
|     assert "sometimes" not in stripped_content | ||||
|     assert "Some content" in stripped_content | ||||
|  | ||||
|     assert b"sometimes" not in stripped_content | ||||
|     assert b"Some content" in stripped_content | ||||
|     # Check that line feeds dont get chewed up when something is found | ||||
|     test_content = "Some initial text\n\nWhich is across multiple lines\n\nZZZZz\n\n\nSo let's see what happens." | ||||
|     ignore = ['something irrelevent but just to check', 'XXXXX', 'YYYYY', 'ZZZZZ'] | ||||
|  | ||||
|     stripped_content = html_tools.strip_ignore_text(test_content, ignore) | ||||
|     assert stripped_content == "Some initial text\n\nWhich is across multiple lines\n\n\n\nSo let's see what happens." | ||||
|  | ||||
| def set_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
| @@ -79,14 +84,14 @@ def set_modified_ignore_response(): | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|  | ||||
| # Ignore text now just removes it entirely, is a LOT more simpler code this way | ||||
|  | ||||
| def test_check_ignore_text_functionality(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Use a mix of case in ZzZ to prove it works case-insensitive. | ||||
|     ignore_text = "XXXXX\r\nYYYYY\r\nzZzZZ\r\nnew ignore stuff" | ||||
|     set_original_ignore_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
| @@ -141,8 +146,6 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|     # Just to be sure.. set a regular modified change.. | ||||
|     set_modified_original_ignore_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
| @@ -151,21 +154,19 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     # Check the preview/highlighter, we should be able to see what we ignored, but it should be highlighted | ||||
|     # We only introduce the "modified" content that includes what we ignore so we can prove the newest version also displays | ||||
|     # at /preview | ||||
|     res = client.get(url_for("preview_page", uuid="first")) | ||||
|     # We should be able to see what we ignored | ||||
|     assert b'<div class="ignored">new ignore stuff' in res.data | ||||
|  | ||||
|     # SHOULD BE be in the preview, it was added in set_modified_original_ignore_response() | ||||
|     # and we have "new ignore stuff" in ignore_text | ||||
|     # it is only ignored, it is not removed (it will be highlighted too) | ||||
|     assert b'new ignore stuff' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| # When adding some ignore text, it should not trigger a change, even if something else on that line changes | ||||
| def test_check_global_ignore_text_functionality(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|     ignore_text = "XXXXX\r\nYYYYY\r\nZZZZZ" | ||||
|     set_original_ignore_response() | ||||
|  | ||||
| @@ -174,6 +175,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem | ||||
|         url_for("settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-ignore_whitespace": "y", | ||||
|             "application-global_ignore_text": ignore_text, | ||||
|             'application-fetch_backend': "html_requests" | ||||
|         }, | ||||
| @@ -194,9 +196,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|  | ||||
|     # Goto the edit page of the item, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     #Adding some ignore text should not trigger a change | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"ignore_text": "something irrelevent but just to check", "url": test_url, 'fetch_backend': "html_requests"}, | ||||
| @@ -212,20 +212,15 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # so that we are sure everything is viewed and in a known 'nothing changed' state | ||||
|     res = client.get(url_for("diff_history_page", uuid="first")) | ||||
|  | ||||
|     # It should report nothing found (no new 'unviewed' class) | ||||
|     # It should report nothing found (no new 'unviewed' class), adding random ignore text should not cause a change | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|     assert b'/test-endpoint' in res.data | ||||
| ##### | ||||
|  | ||||
|  | ||||
|     #  Make a change which includes the ignore text | ||||
|     # Make a change which includes the ignore text, it should be ignored and no 'change' triggered | ||||
|     # It adds text with "ZZZZzzzz" and "ZZZZ" is in the ignore list | ||||
|     set_modified_ignore_response() | ||||
|  | ||||
|     # Trigger a check | ||||
| @@ -235,6 +230,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem | ||||
|  | ||||
|     # It should report nothing found (no new 'unviewed' class) | ||||
|     res = client.get(url_for("index")) | ||||
|  | ||||
|     assert b'unviewed' not in res.data | ||||
|     assert b'/test-endpoint' in res.data | ||||
|  | ||||
|   | ||||
| @@ -499,7 +499,7 @@ def test_correct_header_detect(client, live_server, measure_memory_usage): | ||||
|     ) | ||||
|  | ||||
|     assert b'"hello": 123,' in res.data | ||||
|     assert b'"world": 123</div>' in res.data | ||||
|     assert b'"world": 123' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
							
								
								
									
										78
									
								
								changedetectionio/tests/test_live_preview.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								changedetectionio/tests/test_live_preview.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| from flask import url_for | ||||
| from changedetectionio.tests.util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
|  | ||||
|  | ||||
| def set_response(): | ||||
|  | ||||
|     data = f"""<html> | ||||
|        <body>Awesome, you made it<br> | ||||
| yeah the socks request worked<br> | ||||
| something to ignore<br> | ||||
| something to trigger<br> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(data) | ||||
|  | ||||
| def test_content_filter_live_preview(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     set_response() | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": ''}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid), | ||||
|         data={ | ||||
|             "include_filters": "", | ||||
|             "fetch_backend": 'html_requests', | ||||
|             "ignore_text": "something to ignore", | ||||
|             "trigger_text": "something to trigger", | ||||
|             "url": test_url, | ||||
|         }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # The endpoint is a POST and accepts the form values to override the watch preview | ||||
|     import json | ||||
|  | ||||
|     # DEFAULT OUTPUT WITHOUT ANYTHING UPDATED/CHANGED - SHOULD SEE THE WATCH DEFAULTS | ||||
|     res = client.post( | ||||
|         url_for("watch_get_preview_rendered", uuid=uuid) | ||||
|     ) | ||||
|     default_return = json.loads(res.data.decode('utf-8')) | ||||
|     assert default_return.get('after_filter') | ||||
|     assert default_return.get('before_filter') | ||||
|     assert default_return.get('ignore_line_numbers') == [3] # "something to ignore" line 3 | ||||
|     assert default_return.get('trigger_line_numbers') == [4] # "something to trigger" line 4 | ||||
|  | ||||
|     # SEND AN UPDATE AND WE SHOULD SEE THE OUTPUT CHANGE SO WE KNOW TO HIGHLIGHT NEW STUFF | ||||
|     res = client.post( | ||||
|         url_for("watch_get_preview_rendered", uuid=uuid), | ||||
|         data={ | ||||
|             "include_filters": "", | ||||
|             "fetch_backend": 'html_requests', | ||||
|             "ignore_text": "sOckS", # Also be sure case insensitive works | ||||
|             "trigger_text": "AweSOme", | ||||
|             "url": test_url, | ||||
|         }, | ||||
|     ) | ||||
|     reply = json.loads(res.data.decode('utf-8')) | ||||
|     assert reply.get('after_filter') | ||||
|     assert reply.get('before_filter') | ||||
|     assert reply.get('ignore_line_numbers') == [2]  # Ignored - "socks" on line 2 | ||||
|     assert reply.get('trigger_line_numbers') == [1]  # Triggers "Awesome" in line 1 | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
| @@ -1,11 +1,8 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from urllib.request import urlopen | ||||
| from .util import set_original_response, set_modified_response, live_server_setup | ||||
|  | ||||
| sleep_time_for_fetch_thread = 3 | ||||
| from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks | ||||
| import time | ||||
|  | ||||
|  | ||||
| def set_nonrenderable_response(): | ||||
| @@ -16,12 +13,18 @@ def set_nonrenderable_response(): | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|     time.sleep(1) | ||||
|  | ||||
|     return None | ||||
|  | ||||
| def set_zero_byte_response(): | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write("") | ||||
|     time.sleep(1) | ||||
|     return None | ||||
|  | ||||
| def test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage): | ||||
|     set_original_response() | ||||
|     live_server_setup(live_server) | ||||
| @@ -35,18 +38,11 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|  | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Do this a few times.. ensures we dont accidently set the status | ||||
|     for n in range(3): | ||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|         # Give the thread time to pick it up | ||||
|         time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|         # It should report nothing found (no new 'unviewed' class) | ||||
|         res = client.get(url_for("index")) | ||||
|         assert b'unviewed' not in res.data | ||||
|     # It should report nothing found (no new 'unviewed' class) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|  | ||||
|     ##################### | ||||
| @@ -64,7 +60,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # It should report nothing found (no new 'unviewed' class) | ||||
|     res = client.get(url_for("index")) | ||||
| @@ -86,14 +82,20 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # It should report nothing found (no new 'unviewed' class) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|     client.get(url_for("mark_all_viewed"), follow_redirects=True) | ||||
|  | ||||
|  | ||||
|  | ||||
|     # A totally zero byte (#2528) response should also not trigger an error | ||||
|     set_zero_byte_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data # A change should have registered because empty_pages_are_a_change is ON | ||||
|     assert b'fetch-error' not in res.data | ||||
|  | ||||
|     # | ||||
|     # Cleanup everything | ||||
|   | ||||
| @@ -284,18 +284,18 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me | ||||
|     # CUSTOM JSON BODY CHECK for POST:// | ||||
|     set_original_response() | ||||
|     # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation | ||||
|     test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123" | ||||
|     test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123&+second=hello+world%20%22space%22" | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={ | ||||
|               "application-fetch_backend": "html_requests", | ||||
|               "application-minutes_between_check": 180, | ||||
|               "application-notification_body": '{ "url" : "{{ watch_url }}", "secret": 444 }', | ||||
|               "application-notification_body": '{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了" }', | ||||
|               "application-notification_format": default_notification_format, | ||||
|               "application-notification_urls": test_notification_url, | ||||
|               # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation | ||||
|               "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", | ||||
|               "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }} ", | ||||
|               }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -324,6 +324,8 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me | ||||
|         j = json.loads(x) | ||||
|         assert j['url'].startswith('http://localhost') | ||||
|         assert j['secret'] == 444 | ||||
|         assert j['somebug'] == '网站监测 内容更新了' | ||||
|  | ||||
|  | ||||
|     # URL check, this will always be converted to lowercase | ||||
|     assert os.path.isfile("test-datastore/notification-url.txt") | ||||
| @@ -336,6 +338,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me | ||||
|     with open("test-datastore/notification-headers.txt", 'r') as f: | ||||
|         notification_headers = f.read() | ||||
|         assert 'custom-header: 123' in notification_headers.lower() | ||||
|         assert 'second: hello world "space"' in notification_headers.lower() | ||||
|  | ||||
|  | ||||
|     # Should always be automatically detected as JSON content type even when we set it as 'Text' (default) | ||||
| @@ -354,9 +357,10 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me | ||||
| #2510 | ||||
| def test_global_send_test_notification(client, live_server, measure_memory_usage): | ||||
|  | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|     set_original_response() | ||||
|     if os.path.isfile("test-datastore/notification.txt"): | ||||
|         os.unlink("test-datastore/notification.txt") | ||||
|  | ||||
|     # otherwise other settings would have already existed from previous tests in this file | ||||
|     res = client.post( | ||||
| @@ -364,7 +368,8 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage | ||||
|         data={ | ||||
|             "application-fetch_backend": "html_requests", | ||||
|             "application-minutes_between_check": 180, | ||||
|             "application-notification_body": 'change detection is cool', | ||||
|             #1995 UTF-8 content should be encoded | ||||
|             "application-notification_body": 'change detection is cool 网站监测 内容更新了', | ||||
|             "application-notification_format": default_notification_format, | ||||
|             "application-notification_urls": "", | ||||
|             "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", | ||||
| @@ -399,8 +404,7 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage | ||||
|  | ||||
|     with open("test-datastore/notification.txt", 'r') as f: | ||||
|         x = f.read() | ||||
|         assert 'change detection is coo' in x | ||||
|  | ||||
|         assert 'change detection is cool 网站监测 内容更新了' in x | ||||
|  | ||||
|     os.unlink("test-datastore/notification.txt") | ||||
|  | ||||
| @@ -420,10 +424,22 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage | ||||
|     with open("test-datastore/notification.txt", 'r') as f: | ||||
|         x = f.read() | ||||
|         # Should come from notification.py default handler when there is no notification body to pull from | ||||
|         assert 'change detection is coo' in x | ||||
|         assert 'change detection is cool 网站监测 内容更新了' in x | ||||
|  | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     ######### Test global/system settings - When everything is deleted it should give a helpful error | ||||
|     # See #2727 | ||||
|     res = client.post( | ||||
|         url_for("ajax_callback_send_notification_test")+"?mode=global-settings", | ||||
|         data={"notification_urls": test_notification_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert res.status_code == 400 | ||||
|     assert b"Error: You must have atleast one watch configured for 'test notification' to work" in res.data | ||||
|  | ||||
|  | ||||
|  | ||||
|   | ||||
							
								
								
									
										72
									
								
								changedetectionio/tests/test_preview_endpoints.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								changedetectionio/tests/test_preview_endpoints.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks | ||||
|  | ||||
|  | ||||
| # `subtractive_selectors` should still work in `source:` type requests | ||||
| def test_fetch_pdf(client, live_server, measure_memory_usage): | ||||
|     import shutil | ||||
|     shutil.copy("tests/test.pdf", "test-datastore/endpoint-test.pdf") | ||||
|  | ||||
|     live_server_setup(live_server) | ||||
|     test_url = url_for('test_pdf_endpoint', _external=True) | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     # PDF header should not be there (it was converted to text) | ||||
|     assert b'PDF' not in res.data[:10] | ||||
|     assert b'hello world' in res.data | ||||
|  | ||||
|     # So we know if the file changes in other ways | ||||
|     import hashlib | ||||
|     original_md5 = hashlib.md5(open("test-datastore/endpoint-test.pdf", 'rb').read()).hexdigest().upper() | ||||
|     # We should have one | ||||
|     assert len(original_md5) > 0 | ||||
|     # And it's going to be in the document | ||||
|     assert b'Document checksum - ' + bytes(str(original_md5).encode('utf-8')) in res.data | ||||
|  | ||||
|     shutil.copy("tests/test2.pdf", "test-datastore/endpoint-test.pdf") | ||||
|     changed_md5 = hashlib.md5(open("test-datastore/endpoint-test.pdf", 'rb').read()).hexdigest().upper() | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Now something should be ready, indicated by having a 'unviewed' class | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     # The original checksum should be not be here anymore (cdio adds it to the bottom of the text) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert original_md5.encode('utf-8') not in res.data | ||||
|     assert changed_md5.encode('utf-8') in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("diff_history_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert original_md5.encode('utf-8') in res.data | ||||
|     assert changed_md5.encode('utf-8') in res.data | ||||
|  | ||||
|     assert b'here is a change' in res.data | ||||
| @@ -45,7 +45,7 @@ def test_headers_in_request(client, live_server, measure_memory_usage): | ||||
|               "url": test_url, | ||||
|               "tags": "", | ||||
|               "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', | ||||
|               "headers": "xxx:ooo\ncool:yeah\r\ncookie:"+cookie_header}, | ||||
|               "headers": "jinja2:{{ 1+1 }}\nxxx:ooo\ncool:yeah\r\ncookie:"+cookie_header}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
| @@ -61,6 +61,7 @@ def test_headers_in_request(client, live_server, measure_memory_usage): | ||||
|     ) | ||||
|  | ||||
|     # Flask will convert the header key to uppercase | ||||
|     assert b"Jinja2:2" in res.data | ||||
|     assert b"Xxx:ooo" in res.data | ||||
|     assert b"Cool:yeah" in res.data | ||||
|  | ||||
| @@ -117,7 +118,8 @@ def test_body_in_request(client, live_server, measure_memory_usage): | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Now the change which should trigger a change | ||||
|     body_value = 'Test Body Value' | ||||
|     body_value = 'Test Body Value {{ 1+1 }}' | ||||
|     body_value_formatted = 'Test Body Value 2' | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={ | ||||
| @@ -140,8 +142,9 @@ def test_body_in_request(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # If this gets stuck something is wrong, something should always be there | ||||
|     assert b"No history found" not in res.data | ||||
|     # We should see what we sent in the reply | ||||
|     assert str.encode(body_value) in res.data | ||||
|     # We should see the formatted value of what we sent in the reply | ||||
|     assert str.encode(body_value) not in res.data | ||||
|     assert str.encode(body_value_formatted) in res.data | ||||
|  | ||||
|     ####### data sanity checks | ||||
|     # Add the test URL twice, we will check | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import os | ||||
| import time | ||||
|  | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
| from .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output, extract_UUID_from_client | ||||
| from ..notification import default_notification_format | ||||
|  | ||||
| instock_props = [ | ||||
| @@ -146,14 +146,13 @@ def _run_test_minmax_limit(client, extra_watch_edit_form): | ||||
|         data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     # A change in price, should trigger a change by default | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     data = { | ||||
|         "tags": "", | ||||
|         "url": test_url, | ||||
|         "headers": "", | ||||
|         "time_between_check-hours": 5, | ||||
|         'fetch_backend': "html_requests" | ||||
|     } | ||||
|     data.update(extra_watch_edit_form) | ||||
| @@ -178,11 +177,9 @@ def _run_test_minmax_limit(client, extra_watch_edit_form): | ||||
|     assert b'1,000.45' or b'1000.45' in res.data #depending on locale | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|  | ||||
|     # price changed to something LESS than min (900), SHOULD be a change | ||||
|     set_original_response(props_markup=instock_props[0], price='890.45') | ||||
|     # let previous runs wait | ||||
|     time.sleep(1) | ||||
|  | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|     wait_for_all_checks(client) | ||||
| @@ -197,7 +194,8 @@ def _run_test_minmax_limit(client, extra_watch_edit_form): | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'1,890.45' or b'1890.45' in res.data | ||||
|     # Depending on the LOCALE it may be either of these (generally for US/default/etc) | ||||
|     assert b'1,890.45' in res.data or b'1890.45' in res.data | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
| @@ -362,13 +360,19 @@ def test_change_with_notification_values(client, live_server): | ||||
|     set_original_response(props_markup=instock_props[0], price='1950.45') | ||||
|     client.get(url_for("form_watch_checknow")) | ||||
|     wait_for_all_checks(client) | ||||
|     time.sleep(3) | ||||
|     wait_for_notification_endpoint_output() | ||||
|     assert os.path.isfile("test-datastore/notification.txt"), "Notification received" | ||||
|     with open("test-datastore/notification.txt", 'r') as f: | ||||
|         notification = f.read() | ||||
|         assert "new price 1950.45" in notification | ||||
|         assert "title new price 1950.45" in notification | ||||
|  | ||||
|     ## Now test the "SEND TEST NOTIFICATION" is working | ||||
|     os.unlink("test-datastore/notification.txt") | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     res = client.post(url_for("ajax_callback_send_notification_test", watch_uuid=uuid), data={}, follow_redirects=True) | ||||
|     time.sleep(5) | ||||
|     assert os.path.isfile("test-datastore/notification.txt"), "Notification received" | ||||
|  | ||||
|  | ||||
| def test_data_sanity(client, live_server): | ||||
| @@ -415,3 +419,31 @@ def test_data_sanity(client, live_server): | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first")) | ||||
|     assert test_url2.encode('utf-8') in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| # All examples should give a prive of 666.66 | ||||
| def test_special_prop_examples(client, live_server): | ||||
|     import glob | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     check_path = os.path.join(os.path.dirname(__file__), "itemprop_test_examples", "*.txt") | ||||
|     files = glob.glob(check_path) | ||||
|     assert files | ||||
|     for test_example_filename in files: | ||||
|         with open(test_example_filename, 'r') as example_f: | ||||
|             with open("test-datastore/endpoint-content.txt", "w") as test_f: | ||||
|                 test_f.write(f"<html><body>{example_f.read()}</body></html>") | ||||
|  | ||||
|             # Now fetch it and check the price worked | ||||
|             client.post( | ||||
|                 url_for("form_quick_watch_add"), | ||||
|                 data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, | ||||
|                 follow_redirects=True | ||||
|             ) | ||||
|             wait_for_all_checks(client) | ||||
|             res = client.get(url_for("index")) | ||||
|             assert b'ception' not in res.data | ||||
|             assert b'155.55' in res.data | ||||
|   | ||||
| @@ -61,10 +61,10 @@ def test_bad_access(client, live_server, measure_memory_usage): | ||||
|     assert b'Watch protocol is not permitted by SAFE_PROTOCOL_REGEX' in res.data | ||||
|  | ||||
|  | ||||
| def test_file_access(client, live_server, measure_memory_usage): | ||||
| def test_file_slashslash_access(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     test_file_path = "/tmp/test-file.txt" | ||||
|     test_file_path = os.path.abspath(__file__) | ||||
|  | ||||
|     # file:// is permitted by default, but it will be caught by ALLOW_FILE_URI | ||||
|     client.post( | ||||
| @@ -82,8 +82,30 @@ def test_file_access(client, live_server, measure_memory_usage): | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|  | ||||
|         # Should see something (this file added by run_basic_tests.sh) | ||||
|         assert b"Hello world" in res.data | ||||
|         assert b"test_file_slashslash_access" in res.data | ||||
|     else: | ||||
|         # Default should be here | ||||
|         assert b'file:// type access is denied for security reasons.' in res.data | ||||
|  | ||||
| def test_file_slash_access(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     test_file_path = os.path.abspath(__file__) | ||||
|  | ||||
|     # file:// is permitted by default, but it will be caught by ALLOW_FILE_URI | ||||
|     client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": f"file:/{test_file_path}", "tags": ''}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|  | ||||
|     # If it is enabled at test time | ||||
|     if strtobool(os.getenv('ALLOW_FILE_URI', 'false')): | ||||
|         # So it should permit it, but it should fall back to the 'requests' library giving an error | ||||
|         # (but means it gets passed to playwright etc) | ||||
|         assert b"URLs with hostname components are not permitted" in res.data | ||||
|     else: | ||||
|         # Default should be here | ||||
|         assert b'file:// type access is denied for security reasons.' in res.data | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from . util import live_server_setup | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
|  | ||||
|  | ||||
| def set_original_ignore_response(): | ||||
| @@ -59,12 +59,9 @@ def test_trigger_functionality(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|     trigger_text = "Add to cart" | ||||
|     set_original_ignore_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
| @@ -89,14 +86,14 @@ def test_trigger_functionality(client, live_server, measure_memory_usage): | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|     ) | ||||
|     assert bytes(trigger_text.encode('utf-8')) in res.data | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|      | ||||
|     # so that we set the state to 'unviewed' after all the edits | ||||
|     client.get(url_for("diff_history_page", uuid="first")) | ||||
| @@ -104,8 +101,7 @@ def test_trigger_functionality(client, live_server, measure_memory_usage): | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # It should report nothing found (no new 'unviewed' class) | ||||
|     res = client.get(url_for("index")) | ||||
| @@ -117,19 +113,17 @@ def test_trigger_functionality(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # It should report nothing found (no new 'unviewed' class) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     # Now set the content which contains the trigger text | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     set_modified_with_trigger_text_response() | ||||
|  | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|      | ||||
| @@ -142,4 +136,7 @@ def test_trigger_functionality(client, live_server, measure_memory_usage): | ||||
|     res = client.get(url_for("preview_page", uuid="first")) | ||||
|  | ||||
|     # We should be able to see what we triggered on | ||||
|     assert b'<div class="triggered">Add to cart' in res.data | ||||
|     # The JS highlighter should tell us which lines (also used in the live-preview) | ||||
|     assert b'const triggered_line_numbers = [6]' in res.data | ||||
|     assert b'Add to cart' in res.data | ||||
|  | ||||
|   | ||||
| @@ -11,6 +11,8 @@ def set_original_ignore_response(): | ||||
|      <p>Some initial text</p> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      <p>So let's see what happens.</p> | ||||
|      <p>   So let's see what happens.   <br> </p> | ||||
|      <p>A - sortable line</p>  | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
| @@ -164,5 +166,52 @@ def test_sort_lines_functionality(client, live_server, measure_memory_usage): | ||||
|     assert res.data.find(b'A uppercase') < res.data.find(b'Z last') | ||||
|     assert res.data.find(b'Some initial text') < res.data.find(b'Which is across multiple lines') | ||||
|      | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| def test_extra_filters(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     set_original_ignore_response() | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"remove_duplicate_lines": "y", | ||||
|               "trim_text_whitespace": "y", | ||||
|               "sort_text_alphabetically": "",  # leave this OFF for testing | ||||
|               "url": test_url, | ||||
|               "fetch_backend": "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first") | ||||
|     ) | ||||
|  | ||||
|     assert res.data.count(b"see what happens.") == 1 | ||||
|  | ||||
|     # still should remain unsorted ('A - sortable line') stays at the end | ||||
|     assert res.data.find(b'A - sortable line') > res.data.find(b'Which is across multiple lines') | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
| @@ -161,8 +161,8 @@ def test_check_xpath_text_function_utf8(client, live_server, measure_memory_usag | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b'<div class="">Stock Alert (UK): RPi CM4' in res.data | ||||
|     assert b'<div class="">Stock Alert (UK): Big monitor' in res.data | ||||
|     assert b'Stock Alert (UK): RPi CM4' in res.data | ||||
|     assert b'Stock Alert (UK): Big monitor' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -18,12 +18,13 @@ class TestDiffBuilder(unittest.TestCase): | ||||
|  | ||||
|         watch['last_viewed'] = 110 | ||||
|  | ||||
|         watch.save_history_text(contents=b"hello world", timestamp=100, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents=b"hello world", timestamp=105, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents=b"hello world", timestamp=109, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents=b"hello world", timestamp=112, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents=b"hello world", timestamp=115, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents=b"hello world", timestamp=117, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         # Contents from the browser are always returned from the browser/requests/etc as str, str is basically UTF-16 in python | ||||
|         watch.save_history_text(contents="hello world", timestamp=100, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents="hello world", timestamp=105, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents="hello world", timestamp=109, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents="hello world", timestamp=112, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents="hello world", timestamp=115, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents="hello world", timestamp=117, snapshot_id=str(uuid_builder.uuid4())) | ||||
|  | ||||
|         p = watch.get_next_snapshot_key_to_last_viewed | ||||
|         assert p == "112", "Correct last-viewed timestamp was detected" | ||||
|   | ||||
| @@ -76,6 +76,18 @@ def set_more_modified_response(): | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def wait_for_notification_endpoint_output(): | ||||
|     '''Apprise can take a few seconds to fire''' | ||||
|     #@todo - could check the apprise object directly instead of looking for this file | ||||
|     from os.path import isfile | ||||
|     for i in range(1, 20): | ||||
|         time.sleep(1) | ||||
|         if isfile("test-datastore/notification.txt"): | ||||
|             return True | ||||
|  | ||||
|     return False | ||||
|  | ||||
|  | ||||
| # kinda funky, but works for now | ||||
| def extract_api_key_from_UI(client): | ||||
|     import re | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| from .processors.exceptions import ProcessorException | ||||
| from . import content_fetchers | ||||
|  | ||||
| import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions | ||||
| from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse | ||||
| from changedetectionio import html_tools | ||||
|  | ||||
| @@ -82,7 +81,8 @@ class update_worker(threading.Thread): | ||||
|             'watch_url': watch.get('url') if watch else None, | ||||
|         }) | ||||
|  | ||||
|         n_object.update(watch.extra_notification_token_values()) | ||||
|         if watch: | ||||
|             n_object.update(watch.extra_notification_token_values()) | ||||
|  | ||||
|         logger.trace(f"Main rendered notification placeholders (diff_added etc) calculated in {time.time()-now:.3f}s") | ||||
|         logger.debug("Queued notification for sending") | ||||
| @@ -190,7 +190,9 @@ class update_worker(threading.Thread): | ||||
|                 'screenshot': None | ||||
|             }) | ||||
|             self.notification_q.put(n_object) | ||||
|             logger.error(f"Sent filter not found notification for {watch_uuid}") | ||||
|             logger.debug(f"Sent filter not found notification for {watch_uuid}") | ||||
|         else: | ||||
|             logger.debug(f"NOT sending filter not found notification for {watch_uuid} - no notification URLs") | ||||
|  | ||||
|     def send_step_failure_notification(self, watch_uuid, step_n): | ||||
|         watch = self.datastore.data['watching'].get(watch_uuid, False) | ||||
| @@ -259,9 +261,6 @@ class update_worker(threading.Thread): | ||||
|                     try: | ||||
|                         # Processor is what we are using for detecting the "Change" | ||||
|                         processor = watch.get('processor', 'text_json_diff') | ||||
|                         # Abort processing when the content was the same as the last fetch | ||||
|                         skip_when_same_checksum = queued_item_data.item.get('skip_when_checksum_same') | ||||
|  | ||||
|  | ||||
|                         # Init a new 'difference_detection_processor', first look in processors | ||||
|                         processor_module_name = f"changedetectionio.processors.{processor}.processor" | ||||
| @@ -277,16 +276,13 @@ class update_worker(threading.Thread): | ||||
|  | ||||
|                         update_handler.call_browser() | ||||
|  | ||||
|                         changed_detected, update_obj, contents = update_handler.run_changedetection( | ||||
|                             watch=watch, | ||||
|                             skip_when_checksum_same=skip_when_same_checksum, | ||||
|                         ) | ||||
|                         changed_detected, update_obj, contents = update_handler.run_changedetection(watch=watch) | ||||
|  | ||||
|                         # Re #342 | ||||
|                         # In Python 3, all strings are sequences of Unicode characters. There is a bytes type that holds raw bytes. | ||||
|                         # We then convert/.decode('utf-8') for the notification etc | ||||
|                         if not isinstance(contents, (bytes, bytearray)): | ||||
|                             raise Exception("Error - returned data from the fetch handler SHOULD be bytes") | ||||
| #                        if not isinstance(contents, (bytes, bytearray)): | ||||
| #                            raise Exception("Error - returned data from the fetch handler SHOULD be bytes") | ||||
|                     except PermissionError as e: | ||||
|                         logger.critical(f"File permission error updating file, watch: {uuid}") | ||||
|                         logger.critical(str(e)) | ||||
| @@ -301,7 +297,7 @@ class update_worker(threading.Thread): | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': e.message}) | ||||
|                         process_changedetection_results = False | ||||
|  | ||||
|                     except content_fetchers.exceptions.ReplyWithContentButNoText as e: | ||||
|                     except content_fetchers_exceptions.ReplyWithContentButNoText as e: | ||||
|                         # Totally fine, it's by choice - just continue on, nothing more to care about | ||||
|                         # Page had elements/content but no renderable text | ||||
|                         # Backend (not filters) gave zero output | ||||
| @@ -327,7 +323,7 @@ class update_worker(threading.Thread): | ||||
|                              | ||||
|                         process_changedetection_results = False | ||||
|  | ||||
|                     except content_fetchers.exceptions.Non200ErrorCodeReceived as e: | ||||
|                     except content_fetchers_exceptions.Non200ErrorCodeReceived as e: | ||||
|                         if e.status_code == 403: | ||||
|                             err_text = "Error - 403 (Access denied) received" | ||||
|                         elif e.status_code == 404: | ||||
| @@ -337,7 +333,8 @@ class update_worker(threading.Thread): | ||||
|                         elif e.status_code == 500: | ||||
|                             err_text = "Error - 500 (Internal server error) received from the web site" | ||||
|                         else: | ||||
|                             err_text = "Error - Request returned a HTTP error code {}".format(str(e.status_code)) | ||||
|                             extra = ' (Access denied or blocked)' if str(e.status_code).startswith('4') else '' | ||||
|                             err_text = f"Error - Request returned a HTTP error code {e.status_code}{extra}" | ||||
|  | ||||
|                         if e.screenshot: | ||||
|                             watch.save_screenshot(screenshot=e.screenshot, as_error=True) | ||||
| @@ -365,38 +362,42 @@ class update_worker(threading.Thread): | ||||
|  | ||||
|                         # Only when enabled, send the notification | ||||
|                         if watch.get('filter_failure_notification_send', False): | ||||
|                             c = watch.get('consecutive_filter_failures', 5) | ||||
|                             c = watch.get('consecutive_filter_failures', 0) | ||||
|                             c += 1 | ||||
|                             # Send notification if we reached the threshold? | ||||
|                             threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts', | ||||
|                                                                                            0) | ||||
|                             logger.warning(f"Filter for {uuid} not found, consecutive_filter_failures: {c}") | ||||
|                             if threshold > 0 and c >= threshold: | ||||
|                             threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0) | ||||
|                             logger.debug(f"Filter for {uuid} not found, consecutive_filter_failures: {c} of threshold {threshold}") | ||||
|                             if c >= threshold: | ||||
|                                 if not watch.get('notification_muted'): | ||||
|                                     logger.debug(f"Sending filter failed notification for {uuid}") | ||||
|                                     self.send_filter_failure_notification(uuid) | ||||
|                                 c = 0 | ||||
|                                 logger.debug(f"Reset filter failure count back to zero") | ||||
|  | ||||
|                             self.datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c}) | ||||
|                         else: | ||||
|                             logger.trace(f"{uuid} - filter_failure_notification_send not enabled, skipping") | ||||
|  | ||||
|  | ||||
|                         process_changedetection_results = False | ||||
|  | ||||
|                     except content_fetchers.exceptions.checksumFromPreviousCheckWasTheSame as e: | ||||
|                     except content_fetchers_exceptions.checksumFromPreviousCheckWasTheSame as e: | ||||
|                         # Yes fine, so nothing todo, don't continue to process. | ||||
|                         process_changedetection_results = False | ||||
|                         changed_detected = False | ||||
|                     except content_fetchers.exceptions.BrowserConnectError as e: | ||||
|                     except content_fetchers_exceptions.BrowserConnectError as e: | ||||
|                         self.datastore.update_watch(uuid=uuid, | ||||
|                                                     update_obj={'last_error': e.msg | ||||
|                                                                 } | ||||
|                                                     ) | ||||
|                         process_changedetection_results = False | ||||
|                     except content_fetchers.exceptions.BrowserFetchTimedOut as e: | ||||
|                     except content_fetchers_exceptions.BrowserFetchTimedOut as e: | ||||
|                         self.datastore.update_watch(uuid=uuid, | ||||
|                                                     update_obj={'last_error': e.msg | ||||
|                                                                 } | ||||
|                                                     ) | ||||
|                         process_changedetection_results = False | ||||
|                     except content_fetchers.exceptions.BrowserStepsStepException as e: | ||||
|                     except content_fetchers_exceptions.BrowserStepsStepException as e: | ||||
|  | ||||
|                         if not self.datastore.data['watching'].get(uuid): | ||||
|                             continue | ||||
| @@ -423,7 +424,7 @@ class update_worker(threading.Thread): | ||||
|                                                     ) | ||||
|  | ||||
|                         if watch.get('filter_failure_notification_send', False): | ||||
|                             c = watch.get('consecutive_filter_failures', 5) | ||||
|                             c = watch.get('consecutive_filter_failures', 0) | ||||
|                             c += 1 | ||||
|                             # Send notification if we reached the threshold? | ||||
|                             threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts', | ||||
| @@ -438,25 +439,25 @@ class update_worker(threading.Thread): | ||||
|  | ||||
|                         process_changedetection_results = False | ||||
|  | ||||
|                     except content_fetchers.exceptions.EmptyReply as e: | ||||
|                     except content_fetchers_exceptions.EmptyReply as e: | ||||
|                         # Some kind of custom to-str handler in the exception handler that does this? | ||||
|                         err_text = "EmptyReply - try increasing 'Wait seconds before extracting text', Status Code {}".format(e.status_code) | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||
|                                                                            'last_check_status': e.status_code}) | ||||
|                         process_changedetection_results = False | ||||
|                     except content_fetchers.exceptions.ScreenshotUnavailable as e: | ||||
|                     except content_fetchers_exceptions.ScreenshotUnavailable as e: | ||||
|                         err_text = "Screenshot unavailable, page did not render fully in the expected time or page was too long - try increasing 'Wait seconds before extracting text'" | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||
|                                                                            'last_check_status': e.status_code}) | ||||
|                         process_changedetection_results = False | ||||
|                     except content_fetchers.exceptions.JSActionExceptions as e: | ||||
|                     except content_fetchers_exceptions.JSActionExceptions as e: | ||||
|                         err_text = "Error running JS Actions - Page request - "+e.message | ||||
|                         if e.screenshot: | ||||
|                             watch.save_screenshot(screenshot=e.screenshot, as_error=True) | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||
|                                                                            'last_check_status': e.status_code}) | ||||
|                         process_changedetection_results = False | ||||
|                     except content_fetchers.exceptions.PageUnloadable as e: | ||||
|                     except content_fetchers_exceptions.PageUnloadable as e: | ||||
|                         err_text = "Page request from server didnt respond correctly" | ||||
|                         if e.message: | ||||
|                             err_text = "{} - {}".format(err_text, e.message) | ||||
| @@ -468,7 +469,7 @@ class update_worker(threading.Thread): | ||||
|                                                                            'last_check_status': e.status_code, | ||||
|                                                                            'has_ldjson_price_data': None}) | ||||
|                         process_changedetection_results = False | ||||
|                     except content_fetchers.exceptions.BrowserStepsInUnsupportedFetcher as e: | ||||
|                     except content_fetchers_exceptions.BrowserStepsInUnsupportedFetcher as e: | ||||
|                         err_text = "This watch has Browser Steps configured and so it cannot run with the 'Basic fast Plaintext/HTTP Client', either remove the Browser Steps or select a Chrome fetcher." | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text}) | ||||
|                         process_changedetection_results = False | ||||
| @@ -486,6 +487,8 @@ class update_worker(threading.Thread): | ||||
|                         if not self.datastore.data['watching'].get(uuid): | ||||
|                             continue | ||||
|  | ||||
|                         update_obj['content-type'] = update_handler.fetcher.get_all_headers().get('content-type', '').lower() | ||||
|  | ||||
|                         # Mark that we never had any failures | ||||
|                         if not watch.get('ignore_status_codes'): | ||||
|                             update_obj['consecutive_filter_failures'] = 0 | ||||
|   | ||||
| @@ -18,7 +18,7 @@ services: | ||||
|   # | ||||
|   #        Log levels are in descending order. (TRACE is the most detailed one) | ||||
|   #        Log output levels: TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL | ||||
|   #      - LOGGER_LEVEL=DEBUG | ||||
|   #      - LOGGER_LEVEL=TRACE | ||||
|   # | ||||
|   #       Alternative WebDriver/selenium URL, do not use "'s or 's! | ||||
|   #      - WEBDRIVER_URL=http://browser-chrome:4444/wd/hub | ||||
| @@ -29,8 +29,9 @@ services: | ||||
|   # | ||||
|   #             https://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.common.proxy | ||||
|   # | ||||
|   #       Alternative Playwright URL, do not use "'s or 's! | ||||
|   #      - PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000 | ||||
|   #       Alternative target "Chrome" Playwright URL, do not use "'s or 's! | ||||
|   #       "Playwright" is a driver/librarythat allows changedetection to talk to a Chrome or similar browser. | ||||
|   #      - PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000 | ||||
|   # | ||||
|   #       Playwright proxy settings playwright_proxy_server, playwright_proxy_bypass, playwright_proxy_username, playwright_proxy_password | ||||
|   # | ||||
| @@ -57,6 +58,10 @@ services: | ||||
|   # | ||||
|   #        Absolute minimum seconds to recheck, overrides any watch minimum, change to 0 to disable | ||||
|   #      - MINIMUM_SECONDS_RECHECK_TIME=3 | ||||
|   # | ||||
|   #        If you want to watch local files file:///path/to/file.txt (careful! security implications!) | ||||
|   #      - ALLOW_FILE_URI=False | ||||
|    | ||||
|       # Comment out ports: when using behind a reverse proxy , enable networks: etc. | ||||
|       ports: | ||||
|         - 5000:5000 | ||||
| @@ -69,14 +74,14 @@ services: | ||||
|      # If WEBDRIVER or PLAYWRIGHT are enabled, changedetection container depends on that | ||||
|      # and must wait before starting (substitute "browser-chrome" with "playwright-chrome" if last one is used) | ||||
| #      depends_on: | ||||
| #          playwright-chrome: | ||||
| #          sockpuppetbrowser: | ||||
| #              condition: service_started | ||||
|  | ||||
|  | ||||
|      # Used for fetching pages via Playwright+Chrome where you need Javascript support. | ||||
|      # Sockpuppetbrowser is basically chrome wrapped in an API for allowing fast fetching of web-pages. | ||||
|      # RECOMMENDED FOR FETCHING PAGES WITH CHROME | ||||
| #    playwright-chrome: | ||||
| #        hostname: playwright-chrome | ||||
| #    sockpuppetbrowser: | ||||
| #        hostname: sockpuppetbrowser | ||||
| #        image: dgtlmoon/sockpuppetbrowser:latest | ||||
| #        cap_add: | ||||
| #            - SYS_ADMIN | ||||
|   | ||||
| @@ -35,7 +35,7 @@ dnspython==2.6.1 # related to eventlet fixes | ||||
| # jq not available on Windows so must be installed manually | ||||
|  | ||||
| # Notification library | ||||
| apprise~=1.8.0 | ||||
| apprise==1.9.0 | ||||
|  | ||||
| # apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 | ||||
| # and 2.0.0 https://github.com/dgtlmoon/changedetection.io/issues/2241 not yet compatible | ||||
| @@ -59,7 +59,9 @@ elementpath==4.1.5 | ||||
|  | ||||
| selenium~=4.14.0 | ||||
|  | ||||
| werkzeug~=3.0 | ||||
| # https://github.com/pallets/werkzeug/issues/2985 | ||||
| # Maybe related to pytest? | ||||
| werkzeug==3.0.6 | ||||
|  | ||||
| # Templating, so far just in the URLs but in the future can be for the notifications also | ||||
| jinja2~=3.1 | ||||
| @@ -79,8 +81,9 @@ pyppeteerstealth>=0.0.4 | ||||
| pytest ~=7.2 | ||||
| pytest-flask ~=1.2 | ||||
|  | ||||
| # Pin jsonschema version to prevent build errors on armv6 while rpds-py wheels aren't available (1708) | ||||
| jsonschema==4.17.3 | ||||
| # Anything 4.0 and up but not 5.0 | ||||
| jsonschema ~= 4.0 | ||||
|  | ||||
|  | ||||
| loguru | ||||
|  | ||||
| @@ -92,3 +95,5 @@ babel | ||||
|  | ||||
| # Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096 | ||||
| greenlet >= 3.0.3 | ||||
|  | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user