mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-30 22:27:52 +00:00 
			
		
		
		
	Compare commits
	
		
			54 Commits
		
	
	
		
			memusage-e
			...
			809-global
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | a2fac93548 | ||
|   | 09c9239aab | ||
|   | 59b3bbf195 | ||
|   | aac59ab806 | ||
|   | 7ba8d44677 | ||
|   | 620b22932b | ||
|   | 03424d3419 | ||
|   | f3c7c969d8 | ||
|   | 1355c2a245 | ||
|   | 96cf1a06df | ||
|   | 019a4a0375 | ||
|   | db2f7b80ea | ||
|   | bfabd7b094 | ||
|   | d92dbfe765 | ||
|   | 67d2441334 | ||
|   | 3c30bc02d5 | ||
|   | dcb54117d5 | ||
|   | b1e32275dc | ||
|   | e2a6865932 | ||
|   | f04adb7202 | ||
|   | 1193a7f22c | ||
|   | 0b976827bb | ||
|   | 280e916033 | ||
|   | 5494e61a05 | ||
|   | e461c0b819 | ||
|   | d67c654f37 | ||
|   | 06ab34b6af | ||
|   | ba8676c4ba | ||
|   | 4899c1a4f9 | ||
|   | 9bff1582f7 | ||
|   | 269e3bb7c5 | ||
|   | 9976f3f969 | ||
|   | 1f250aa868 | ||
|   | 1c08d9f150 | ||
|   | 9942107016 | ||
|   | 1eb5726cbf | ||
|   | b3271ff7bb | ||
|   | f82d3b648a | ||
|   | 034b1330d4 | ||
|   | a7d005109f | ||
|   | 048c355e04 | ||
|   | 4026575b0b | ||
|   | 8c466b4826 | ||
|   | 6f072b42e8 | ||
|   | e318253f31 | ||
|   | f0f2fe94ce | ||
|   | 26f5c56ba4 | ||
|   | a1c3107cd6 | ||
|   | 8fef3ff4ab | ||
|   | baa25c9f9e | ||
|   | 488699b7d4 | ||
|   | cf3a1ee3e3 | ||
|   | daae43e9f9 | ||
|   | cdeedaa65c | 
							
								
								
									
										14
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							| @@ -7,6 +7,20 @@ assignees: 'dgtlmoon' | ||||
|  | ||||
| --- | ||||
|  | ||||
| **DO NOT USE THIS FORM TO REPORT THAT A PARTICULAR WEBSITE IS NOT SCRAPING/WATCHING AS EXPECTED** | ||||
|  | ||||
| This form is only for direct bugs and feature requests todo directly with the software. | ||||
|  | ||||
| Please report watched websites (full URL and _any_ settings) that do not work with changedetection.io as expected [**IN THE DISCUSSION FORUMS**](https://github.com/dgtlmoon/changedetection.io/discussions) or your report will be deleted | ||||
|  | ||||
| CONSIDER TAKING OUT A SUBSCRIPTION FOR A SMALL PRICE PER MONTH, YOU GET THE BENEFIT OF USING OUR PAID PROXIES AND FURTHERING THE DEVELOPMENT OF CHANGEDETECTION.IO | ||||
|  | ||||
| THANK YOU | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| **Describe the bug** | ||||
| A clear and concise description of what the bug is. | ||||
|  | ||||
|   | ||||
| @@ -22,7 +22,7 @@ RUN pip install --target=/dependencies -r /requirements.txt | ||||
|  | ||||
| # Playwright is an alternative to Selenium | ||||
| # Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing | ||||
| RUN pip install --target=/dependencies playwright~=1.20 \ | ||||
| RUN pip install --target=/dependencies playwright~=1.24 \ | ||||
|     || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled." | ||||
|  | ||||
| # Final image stage | ||||
|   | ||||
							
								
								
									
										39
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										39
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,25 +1,16 @@ | ||||
| #  changedetection.io | ||||
| ## Web Site Change Detection, Monitoring and Notification. | ||||
|  | ||||
| Live your data-life pro-actively, track website content changes and receive notifications via Discord, Email, Slack, Telegram and 70+ more | ||||
|  | ||||
| [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring"  title="Self-hosted web page change monitoring"  />](https://lemonade.changedetection.io/start) | ||||
|  | ||||
| [![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md) | ||||
|  | ||||
|  | ||||
|  | ||||
| ## Web Site Change Detection, Monitoring and Notification - Self-Hosted or SaaS. | ||||
| Know when important content changes, we support notifications via Discord, Telegram, Home-Assistant, Slack, Email and 70+ more | ||||
|  | ||||
| _Know when web pages change! Stay ontop of new information! get notifications when important website content changes_  | ||||
|  | ||||
| Live your data-life *pro-actively* instead of *re-actively*. | ||||
|  | ||||
| Free, Open-source web page monitoring, notification and change detection. Don't have time? [**Try our $6.99/month subscription - unlimited checks and watches!**](https://lemonade.changedetection.io/start) | ||||
|  | ||||
| [](https://discord.gg/vUNt4EtWMF) [ ](https://www.youtube.com/channel/UCbS09q1TRf0o4N2t-WA3emQ) [](https://www.linkedin.com/company/changedetection-io/) | ||||
|  | ||||
|  | ||||
| [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring"  title="Self-hosted web page change monitoring"  />](https://lemonade.changedetection.io/start) | ||||
|  | ||||
|  | ||||
| **Get your own private instance now! Let us host it for you!** | ||||
|  | ||||
| [**Try our $6.99/month subscription - unlimited checks and watches!**](https://lemonade.changedetection.io/start) , _half the price of other website change monitoring services and comes with unlimited watches & checks!_ | ||||
| [**Don't have time? Let us host it for you! try our $6.99/month subscription - use our proxies and support!**](https://lemonade.changedetection.io/start) , _half the price of other website change monitoring services and comes with unlimited watches & checks!_ | ||||
|  | ||||
|  | ||||
|  | ||||
| @@ -31,6 +22,7 @@ Free, Open-source web page monitoring, notification and change detection. Don't | ||||
| #### Example use cases | ||||
|  | ||||
| - Products and services have a change in pricing | ||||
| - _Out of stock notification_ and _Back In stock notification_ | ||||
| - Governmental department updates (changes are often only on their websites) | ||||
| - New software releases, security advisories when you're not on their mailing list. | ||||
| - Festivals with changes | ||||
| @@ -47,7 +39,18 @@ Free, Open-source web page monitoring, notification and change detection. Don't | ||||
| - Monitor HTML source code for unexpected changes, strengthen your PCI compliance | ||||
| - You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product) | ||||
|  | ||||
| _Need an actual Chrome runner with Javascript support? We support fetching via WebDriver!</a>_ | ||||
| _Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_ | ||||
|  | ||||
| #### Key Features | ||||
|  | ||||
| - Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions! | ||||
| - Target elements with xPath and CSS Selectors, Easily monitor complex JSON with JsonPath rules | ||||
| - Switch between fast non-JS and Chrome JS based "fetchers" | ||||
| - Easily specify how often a site should be checked | ||||
| - Execute JS before extracting text (Good for logging in, see examples in the UI!) | ||||
| - Override Request Headers, Specify `POST` or `GET` and other methods | ||||
| - Use the "Visual Selector" to help target specific elements | ||||
|  | ||||
|  | ||||
| ## Screenshots | ||||
|  | ||||
|   | ||||
| @@ -6,6 +6,36 @@ | ||||
| # Read more https://github.com/dgtlmoon/changedetection.io/wiki | ||||
|  | ||||
| from changedetectionio import changedetection | ||||
| import multiprocessing | ||||
| import signal | ||||
| import os | ||||
|  | ||||
| def sigchld_handler(_signo, _stack_frame): | ||||
|     import sys | ||||
|     print('Shutdown: Got SIGCHLD') | ||||
|     # https://stackoverflow.com/questions/40453496/python-multiprocessing-capturing-signals-to-restart-child-processes-or-shut-do | ||||
|     pid, status = os.waitpid(-1, os.WNOHANG | os.WUNTRACED | os.WCONTINUED) | ||||
|  | ||||
|     print('Sub-process: pid %d status %d' % (pid, status)) | ||||
|     if status != 0: | ||||
|         sys.exit(1) | ||||
|  | ||||
|     raise SystemExit | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|     changedetection.main() | ||||
|  | ||||
|     #signal.signal(signal.SIGCHLD, sigchld_handler) | ||||
|  | ||||
|     # The only way I could find to get Flask to shutdown, is to wrap it and then rely on the subsystem issuing SIGTERM/SIGKILL | ||||
|     parse_process = multiprocessing.Process(target=changedetection.main) | ||||
|     parse_process.daemon = True | ||||
|     parse_process.start() | ||||
|     import time | ||||
|  | ||||
|     try: | ||||
|         while True: | ||||
|             time.sleep(1) | ||||
|  | ||||
|     except KeyboardInterrupt: | ||||
|         #parse_process.terminate() not needed, because this process will issue it to the sub-process anyway | ||||
|         print ("Exited - CTRL+C") | ||||
|   | ||||
| @@ -44,7 +44,7 @@ from flask_wtf import CSRFProtect | ||||
| from changedetectionio import html_tools | ||||
| from changedetectionio.api import api_v1 | ||||
|  | ||||
| __version__ = '0.39.17' | ||||
| __version__ = '0.39.18' | ||||
|  | ||||
| datastore = None | ||||
|  | ||||
| @@ -54,7 +54,7 @@ ticker_thread = None | ||||
|  | ||||
| extra_stylesheets = [] | ||||
|  | ||||
| update_q = queue.Queue() | ||||
| update_q = queue.PriorityQueue() | ||||
|  | ||||
| notification_q = queue.Queue() | ||||
|  | ||||
| @@ -76,7 +76,7 @@ app.config['LOGIN_DISABLED'] = False | ||||
|  | ||||
| # Disables caching of the templates | ||||
| app.config['TEMPLATES_AUTO_RELOAD'] = True | ||||
|  | ||||
| app.jinja_env.add_extension('jinja2.ext.loopcontrols') | ||||
| csrf = CSRFProtect() | ||||
| csrf.init_app(app) | ||||
|  | ||||
| @@ -115,18 +115,19 @@ def _jinja2_filter_datetime(watch_obj, format="%Y-%m-%d %H:%M:%S"): | ||||
|  | ||||
|     return timeago.format(int(watch_obj['last_checked']), time.time()) | ||||
|  | ||||
|  | ||||
| # @app.context_processor | ||||
| # def timeago(): | ||||
| #    def _timeago(lower_time, now): | ||||
| #        return timeago.format(lower_time, now) | ||||
| #    return dict(timeago=_timeago) | ||||
|  | ||||
| @app.template_filter('format_timestamp_timeago') | ||||
| def _jinja2_filter_datetimestamp(timestamp, format="%Y-%m-%d %H:%M:%S"): | ||||
|     if timestamp == False: | ||||
|         return 'Not yet' | ||||
|  | ||||
|     return timeago.format(timestamp, time.time()) | ||||
|     # return timeago.format(timestamp, time.time()) | ||||
|     # return datetime.datetime.utcfromtimestamp(timestamp).strftime(format) | ||||
|  | ||||
| @app.template_filter('format_seconds_ago') | ||||
| def _jinja2_filter_seconds_precise(timestamp): | ||||
|     if timestamp == False: | ||||
|         return 'Not yet' | ||||
|  | ||||
|     return format(int(time.time()-timestamp), ',d') | ||||
|  | ||||
| # When nobody is logged in Flask-Login's current_user is set to an AnonymousUser object. | ||||
| class User(flask_login.UserMixin): | ||||
| @@ -313,7 +314,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                 watch['uuid'] = uuid | ||||
|                 sorted_watches.append(watch) | ||||
|  | ||||
|         sorted_watches.sort(key=lambda x: x['last_changed'], reverse=True) | ||||
|         sorted_watches.sort(key=lambda x: x.last_changed, reverse=False) | ||||
|  | ||||
|         fg = FeedGenerator() | ||||
|         fg.title('changedetection.io') | ||||
| @@ -332,7 +333,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             if not watch.viewed: | ||||
|                 # Re #239 - GUID needs to be individual for each event | ||||
|                 # @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228) | ||||
|                 guid = "{}/{}".format(watch['uuid'], watch['last_changed']) | ||||
|                 guid = "{}/{}".format(watch['uuid'], watch.last_changed) | ||||
|                 fe = fg.add_entry() | ||||
|  | ||||
|                 # Include a link to the diff page, they will have to login here to see if password protection is enabled. | ||||
| @@ -370,20 +371,20 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         from changedetectionio import forms | ||||
|  | ||||
|         limit_tag = request.args.get('tag') | ||||
|         pause_uuid = request.args.get('pause') | ||||
|  | ||||
|         # Redirect for the old rss path which used the /?rss=true | ||||
|         if request.args.get('rss'): | ||||
|             return redirect(url_for('rss', tag=limit_tag)) | ||||
|  | ||||
|         if pause_uuid: | ||||
|             try: | ||||
|                 datastore.data['watching'][pause_uuid]['paused'] ^= True | ||||
|                 datastore.needs_write = True | ||||
|         op = request.args.get('op') | ||||
|         if op: | ||||
|             uuid = request.args.get('uuid') | ||||
|             if op == 'pause': | ||||
|                 datastore.data['watching'][uuid]['paused'] ^= True | ||||
|             elif op == 'mute': | ||||
|                 datastore.data['watching'][uuid]['notification_muted'] ^= True | ||||
|  | ||||
|                 return redirect(url_for('index', tag = limit_tag)) | ||||
|             except KeyError: | ||||
|                 pass | ||||
|             datastore.needs_write = True | ||||
|             return redirect(url_for('index', tag = limit_tag)) | ||||
|  | ||||
|         # Sort by last_changed and add the uuid which is usually the key.. | ||||
|         sorted_watches = [] | ||||
| @@ -406,7 +407,6 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         existing_tags = datastore.get_all_tags() | ||||
|  | ||||
|         form = forms.quickWatchForm(request.form) | ||||
|  | ||||
|         output = render_template("watch-overview.html", | ||||
|                                  form=form, | ||||
|                                  watches=sorted_watches, | ||||
| @@ -417,7 +417,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                                  # Don't link to hosting when we're on the hosting environment | ||||
|                                  hosted_sticky=os.getenv("SALTED_PASS", False) == False, | ||||
|                                  guid=datastore.data['app_guid'], | ||||
|                                  queued_uuids=update_q.queue) | ||||
|                                  queued_uuids=[uuid for p,uuid in update_q.queue]) | ||||
|  | ||||
|  | ||||
|         if session.get('share-link'): | ||||
| @@ -631,18 +631,14 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             # But in the case something is added we should save straight away | ||||
|             datastore.needs_write_urgent = True | ||||
|  | ||||
|             # Queue the watch for immediate recheck | ||||
|             update_q.put(uuid) | ||||
|             # Queue the watch for immediate recheck, with a higher priority | ||||
|             update_q.put((1, uuid)) | ||||
|  | ||||
|             # Diff page [edit] link should go back to diff page | ||||
|             if request.args.get("next") and request.args.get("next") == 'diff' and not form.save_and_preview_button.data: | ||||
|             if request.args.get("next") and request.args.get("next") == 'diff': | ||||
|                 return redirect(url_for('diff_history_page', uuid=uuid)) | ||||
|             else: | ||||
|                 if form.save_and_preview_button.data: | ||||
|                     flash('You may need to reload this page to see the new content.') | ||||
|                     return redirect(url_for('preview_page', uuid=uuid)) | ||||
|                 else: | ||||
|                     return redirect(url_for('index')) | ||||
|  | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         else: | ||||
|             if request.method == 'POST' and not form.validate(): | ||||
| @@ -708,7 +704,14 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                     return redirect(url_for('settings_page')) | ||||
|  | ||||
|             if form.validate(): | ||||
|                 datastore.data['settings']['application'].update(form.data['application']) | ||||
|                 # Don't set password to False when a password is set - should be only removed with the `removepassword` button | ||||
|                 app_update = dict(deepcopy(form.data['application'])) | ||||
|  | ||||
|                 # Never update password with '' or False (Added by wtforms when not in submission) | ||||
|                 if 'password' in app_update and not app_update['password']: | ||||
|                     del (app_update['password']) | ||||
|  | ||||
|                 datastore.data['settings']['application'].update(app_update) | ||||
|                 datastore.data['settings']['requests'].update(form.data['requests']) | ||||
|  | ||||
|                 if not os.getenv("SALTED_PASS", False) and len(form.application.form.password.encrypted_password): | ||||
| @@ -746,7 +749,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                 importer = import_url_list() | ||||
|                 importer.run(data=request.values.get('urls'), flash=flash, datastore=datastore) | ||||
|                 for uuid in importer.new_uuids: | ||||
|                     update_q.put(uuid) | ||||
|                     update_q.put((1, uuid)) | ||||
|  | ||||
|                 if len(importer.remaining_data) == 0: | ||||
|                     return redirect(url_for('index')) | ||||
| @@ -759,7 +762,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(uuid) | ||||
|                     update_q.put((1, uuid)) | ||||
|  | ||||
|  | ||||
|  | ||||
| @@ -828,7 +831,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             previous_version_file_contents = "Unable to read {}.\n".format(previous_file) | ||||
|  | ||||
|  | ||||
|         screenshot_url = datastore.get_screenshot(uuid) | ||||
|         screenshot_url = watch.get_screenshot() | ||||
|  | ||||
|         system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver' | ||||
|  | ||||
| @@ -848,7 +851,11 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                                  extra_title=" - Diff - {}".format(watch['title'] if watch['title'] else watch['url']), | ||||
|                                  left_sticky=True, | ||||
|                                  screenshot=screenshot_url, | ||||
|                                  is_html_webdriver=is_html_webdriver) | ||||
|                                  is_html_webdriver=is_html_webdriver, | ||||
|                                  last_error=watch['last_error'], | ||||
|                                  last_error_text=watch.get_error_text(), | ||||
|                                  last_error_screenshot=watch.get_error_snapshot() | ||||
|                                  ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
| @@ -863,73 +870,82 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         # Normally you would never reach this, because the 'preview' button is not available when there's no history | ||||
|         # However they may try to clear snapshots and reload the page | ||||
|         if datastore.data['watching'][uuid].history_n == 0: | ||||
|             flash("Preview unavailable - No fetch/check completed or triggers not reached", "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')] | ||||
|  | ||||
|         try: | ||||
|             watch = datastore.data['watching'][uuid] | ||||
|         except KeyError: | ||||
|             flash("No history found for the specified link, bad link?", "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         if watch.history_n >0: | ||||
|             timestamps = sorted(watch.history.keys(), key=lambda x: int(x)) | ||||
|             filename = watch.history[timestamps[-1]] | ||||
|             try: | ||||
|                 with open(filename, 'r') as f: | ||||
|                     tmp = f.readlines() | ||||
|  | ||||
|                     # 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="".join(tmp), | ||||
|                                                                         wordlist=ignore_rules, | ||||
|                                                                         mode='line numbers' | ||||
|                                                                         ) | ||||
|  | ||||
|                     trigger_line_numbers = html_tools.strip_ignore_text(content="".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)}) | ||||
|  | ||||
|  | ||||
|             except Exception as e: | ||||
|                 content.append({'line': "File doesnt exist or unable to read file {}".format(filename), 'classes': ''}) | ||||
|         else: | ||||
|             content.append({'line': "No history found", 'classes': ''}) | ||||
|  | ||||
|         screenshot_url = datastore.get_screenshot(uuid) | ||||
|         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 = True if watch.get('fetch_backend') == 'html_webdriver' or ( | ||||
|                 watch.get('fetch_backend', None) is None and system_uses_webdriver) else False | ||||
|  | ||||
|         # Never requested successfully, but we detected a fetch error | ||||
|         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") | ||||
|             output = render_template("preview.html", | ||||
|                                      content=content, | ||||
|                                      history_n=watch.history_n, | ||||
|                                      extra_stylesheets=extra_stylesheets, | ||||
| #                                     current_diff_url=watch['url'], | ||||
|                                      watch=watch, | ||||
|                                      uuid=uuid, | ||||
|                                      is_html_webdriver=is_html_webdriver, | ||||
|                                      last_error=watch['last_error'], | ||||
|                                      last_error_text=watch.get_error_text(), | ||||
|                                      last_error_screenshot=watch.get_error_snapshot()) | ||||
|             return output | ||||
|  | ||||
|         timestamp = list(watch.history.keys())[-1] | ||||
|         filename = watch.history[timestamp] | ||||
|         try: | ||||
|             with open(filename, 'r') as f: | ||||
|                 tmp = f.readlines() | ||||
|  | ||||
|                 # 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="".join(tmp), | ||||
|                                                                     wordlist=ignore_rules, | ||||
|                                                                     mode='line numbers' | ||||
|                                                                     ) | ||||
|  | ||||
|                 trigger_line_numbers = html_tools.strip_ignore_text(content="".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)}) | ||||
|  | ||||
|         except Exception as e: | ||||
|             content.append({'line': "File doesnt exist or unable to read file {}".format(filename), 'classes': ''}) | ||||
|  | ||||
|         output = render_template("preview.html", | ||||
|                                  content=content, | ||||
|                                  history_n=watch.history_n, | ||||
|                                  extra_stylesheets=extra_stylesheets, | ||||
|                                  ignored_line_numbers=ignored_line_numbers, | ||||
|                                  triggered_line_numbers=trigger_line_numbers, | ||||
|                                  current_diff_url=watch['url'], | ||||
|                                  screenshot=screenshot_url, | ||||
|                                  screenshot=watch.get_screenshot(), | ||||
|                                  watch=watch, | ||||
|                                  uuid=uuid, | ||||
|                                  is_html_webdriver=is_html_webdriver) | ||||
|                                  is_html_webdriver=is_html_webdriver, | ||||
|                                  last_error=watch['last_error'], | ||||
|                                  last_error_text=watch.get_error_text(), | ||||
|                                  last_error_screenshot=watch.get_error_snapshot()) | ||||
|  | ||||
|         return output | ||||
|  | ||||
| @@ -1029,11 +1045,12 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated: | ||||
|                 abort(403) | ||||
|  | ||||
|             screenshot_filename = "last-screenshot.png" if not request.args.get('error_screenshot') else "last-error-screenshot.png" | ||||
|  | ||||
|             # These files should be in our subdirectory | ||||
|             try: | ||||
|                 # set nocache, set content-type | ||||
|                 watch_dir = datastore_o.datastore_path + "/" + filename | ||||
|                 response = make_response(send_from_directory(filename="last-screenshot.png", directory=watch_dir, path=watch_dir + "/last-screenshot.png")) | ||||
|                 response = make_response(send_from_directory(os.path.join(datastore_o.datastore_path, filename), screenshot_filename)) | ||||
|                 response.headers['Content-type'] = 'image/png' | ||||
|                 response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' | ||||
|                 response.headers['Pragma'] = 'no-cache' | ||||
| @@ -1090,7 +1107,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         if not add_paused and new_uuid: | ||||
|             # Straight into the queue. | ||||
|             update_q.put(new_uuid) | ||||
|             update_q.put((1, new_uuid)) | ||||
|             flash("Watch added.") | ||||
|  | ||||
|         if add_paused: | ||||
| @@ -1127,7 +1144,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         new_uuid = datastore.clone(uuid) | ||||
|         update_q.put(new_uuid) | ||||
|         update_q.put((5, new_uuid)) | ||||
|         flash('Cloned.') | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
| @@ -1148,7 +1165,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         if uuid: | ||||
|             if uuid not in running_uuids: | ||||
|                 update_q.put(uuid) | ||||
|                 update_q.put((1, uuid)) | ||||
|             i = 1 | ||||
|  | ||||
|         elif tag != None: | ||||
| @@ -1156,7 +1173,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|                 if (tag != None and tag in watch['tag']): | ||||
|                     if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']: | ||||
|                         update_q.put(watch_uuid) | ||||
|                         update_q.put((1, watch_uuid)) | ||||
|                         i += 1 | ||||
|  | ||||
|         else: | ||||
| @@ -1164,11 +1181,41 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|  | ||||
|                 if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']: | ||||
|                     update_q.put(watch_uuid) | ||||
|                     update_q.put((1, watch_uuid)) | ||||
|                     i += 1 | ||||
|         flash("{} watches are queued for rechecking.".format(i)) | ||||
|         return redirect(url_for('index', tag=tag)) | ||||
|  | ||||
|     @app.route("/form/checkbox-operations", methods=['POST']) | ||||
|     @login_required | ||||
|     def form_watch_list_checkbox_operations(): | ||||
|         op = request.form['op'] | ||||
|         uuids = request.form.getlist('uuids') | ||||
|  | ||||
|         if (op == 'delete'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.delete(uuid.strip()) | ||||
|             flash("{} watches deleted".format(len(uuids))) | ||||
|  | ||||
|         if (op == 'pause'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = True | ||||
|  | ||||
|             flash("{} watches paused".format(len(uuids))) | ||||
|  | ||||
|         if (op == 'unpause'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = False | ||||
|             flash("{} watches unpaused".format(len(uuids))) | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|     @app.route("/api/share-url", methods=['GET']) | ||||
|     @login_required | ||||
|     def form_share_put_watch(): | ||||
| @@ -1266,7 +1313,6 @@ def notification_runner(): | ||||
|     global notification_debug_log | ||||
|     from datetime import datetime | ||||
|     import json | ||||
|  | ||||
|     while not app.config.exit.is_set(): | ||||
|         try: | ||||
|             # At the moment only one thread runs (single runner) | ||||
| @@ -1368,14 +1414,19 @@ def ticker_thread_check_time_launch_checks(): | ||||
|  | ||||
|             seconds_since_last_recheck = now - watch['last_checked'] | ||||
|             if seconds_since_last_recheck >= (threshold + watch.jitter_seconds) and seconds_since_last_recheck >= recheck_time_minimum_seconds: | ||||
|                 if not uuid in running_uuids and uuid not in update_q.queue: | ||||
|                     print("Queued watch UUID {} last checked at {} queued at {:0.2f} jitter {:0.2f}s, {:0.2f}s since last checked".format(uuid, | ||||
|                                                                                                          watch['last_checked'], | ||||
|                                                                                                          now, | ||||
|                                                                                                          watch.jitter_seconds, | ||||
|                                                                                                          now - watch['last_checked'])) | ||||
|                 if not uuid in running_uuids and uuid not in [q_uuid for p,q_uuid in update_q.queue]: | ||||
|                     # Use Epoch time as priority, so we get a "sorted" PriorityQueue, but we can still push a priority 1 into it. | ||||
|                     priority = int(time.time()) | ||||
|                     print( | ||||
|                         "> Queued watch UUID {} last checked at {} queued at {:0.2f} priority {} jitter {:0.2f}s, {:0.2f}s since last checked".format( | ||||
|                             uuid, | ||||
|                             watch['last_checked'], | ||||
|                             now, | ||||
|                             priority, | ||||
|                             watch.jitter_seconds, | ||||
|                             now - watch['last_checked'])) | ||||
|                     # Into the queue with you | ||||
|                     update_q.put(uuid) | ||||
|                     update_q.put((priority, uuid)) | ||||
|  | ||||
|                     # Reset for next time | ||||
|                     watch.jitter_seconds = 0 | ||||
|   | ||||
| @@ -24,7 +24,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(uuid) | ||||
|             self.update_q.put((1, uuid)) | ||||
|             return "OK", 200 | ||||
|  | ||||
|         # Return without history, get that via another API call | ||||
| @@ -100,7 +100,7 @@ class CreateWatch(Resource): | ||||
|         extras = {'title': json_data['title'].strip()} if json_data.get('title') else {} | ||||
|  | ||||
|         new_uuid = self.datastore.add_watch(url=json_data['url'].strip(), tag=tag, extras=extras) | ||||
|         self.update_q.put(new_uuid) | ||||
|         self.update_q.put((1, new_uuid)) | ||||
|         return {'uuid': new_uuid}, 201 | ||||
|  | ||||
|     # Return concise list of available watches and some very basic info | ||||
| @@ -113,12 +113,12 @@ class CreateWatch(Resource): | ||||
|             list[k] = {'url': v['url'], | ||||
|                        'title': v['title'], | ||||
|                        'last_checked': v['last_checked'], | ||||
|                        'last_changed': v['last_changed'], | ||||
|                        'last_changed': v.last_changed, | ||||
|                        'last_error': v['last_error']} | ||||
|  | ||||
|         if request.args.get('recheck_all'): | ||||
|             for uuid in self.datastore.data['watching'].keys(): | ||||
|                 self.update_q.put(uuid) | ||||
|                 self.update_q.put((1, uuid)) | ||||
|             return {'status': "OK"}, 200 | ||||
|  | ||||
|         return list, 200 | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
|  | ||||
| import getopt | ||||
| import os | ||||
| import signal | ||||
| import sys | ||||
|  | ||||
| import eventlet | ||||
| @@ -11,7 +12,21 @@ import eventlet.wsgi | ||||
| from . import store, changedetection_app, content_fetcher | ||||
| from . import __version__ | ||||
|  | ||||
| # Only global so we can access it in the signal handler | ||||
| datastore = None | ||||
| app = None | ||||
|  | ||||
| def sigterm_handler(_signo, _stack_frame): | ||||
|     global app | ||||
|     global datastore | ||||
| #    app.config.exit.set() | ||||
|     print('Shutdown: Got SIGTERM, DB saved to disk') | ||||
|     datastore.sync_to_json() | ||||
| #    raise SystemExit | ||||
|  | ||||
| def main(): | ||||
|     global datastore | ||||
|     global app | ||||
|     ssl_mode = False | ||||
|     host = '' | ||||
|     port = os.environ.get('PORT') or 5000 | ||||
| @@ -35,11 +50,6 @@ def main(): | ||||
|     create_datastore_dir = False | ||||
|  | ||||
|     for opt, arg in opts: | ||||
|         #        if opt == '--clear-all-history': | ||||
|         # Remove history, the actual files you need to delete manually. | ||||
|         #            for uuid, watch in datastore.data['watching'].items(): | ||||
|         #                watch.update({'history': {}, 'last_checked': 0, 'last_changed': 0, 'previous_md5': None}) | ||||
|  | ||||
|         if opt == '-s': | ||||
|             ssl_mode = True | ||||
|  | ||||
| @@ -72,9 +82,12 @@ def main(): | ||||
|                 "Or use the -C parameter to create the directory.".format(app_config['datastore_path']), file=sys.stderr) | ||||
|             sys.exit(2) | ||||
|  | ||||
|  | ||||
|     datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], version_tag=__version__) | ||||
|     app = changedetection_app(app_config, datastore) | ||||
|  | ||||
|     signal.signal(signal.SIGTERM, sigterm_handler) | ||||
|  | ||||
|     # Go into cleanup mode | ||||
|     if do_cleanup: | ||||
|         datastore.remove_unused_snapshots() | ||||
| @@ -111,4 +124,3 @@ def main(): | ||||
|     else: | ||||
|         eventlet.wsgi.server(eventlet.listen((host, int(port))), app) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -6,38 +6,64 @@ import requests | ||||
| import time | ||||
| import sys | ||||
|  | ||||
| class PageUnloadable(Exception): | ||||
|     def __init__(self, status_code, url): | ||||
|  | ||||
| class Non200ErrorCodeReceived(Exception): | ||||
|     def __init__(self, status_code, url, screenshot=None, xpath_data=None, page_html=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         self.xpath_data = xpath_data | ||||
|         self.page_text = None | ||||
|  | ||||
|         if page_html: | ||||
|             from changedetectionio import html_tools | ||||
|             self.page_text = html_tools.html_to_text(page_html) | ||||
|         return | ||||
|  | ||||
|  | ||||
| class JSActionExceptions(Exception): | ||||
|     def __init__(self, status_code, url, screenshot, message=''): | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         self.message = message | ||||
|         return | ||||
|  | ||||
| class PageUnloadable(Exception): | ||||
|     def __init__(self, status_code, url, screenshot=False, message=False): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         self.message = message | ||||
|         return | ||||
|     pass | ||||
|  | ||||
| class EmptyReply(Exception): | ||||
|     def __init__(self, status_code, url): | ||||
|     def __init__(self, status_code, url, screenshot=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         return | ||||
|     pass | ||||
|  | ||||
| class ScreenshotUnavailable(Exception): | ||||
|     def __init__(self, status_code, url): | ||||
|     def __init__(self, status_code, url, page_html=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         if page_html: | ||||
|             from html_tools import html_to_text | ||||
|             self.page_text = html_to_text(page_html) | ||||
|         return | ||||
|     pass | ||||
|  | ||||
| class ReplyWithContentButNoText(Exception): | ||||
|     def __init__(self, status_code, url): | ||||
|     def __init__(self, status_code, url, screenshot=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         return | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Fetcher(): | ||||
|     error = None | ||||
| @@ -180,7 +206,7 @@ class Fetcher(): | ||||
|     system_https_proxy = os.getenv('HTTPS_PROXY') | ||||
|  | ||||
|     # Time ONTOP of the system defined env minimum time | ||||
|     render_extract_delay=0 | ||||
|     render_extract_delay = 0 | ||||
|  | ||||
|     @abstractmethod | ||||
|     def get_error(self): | ||||
| @@ -267,7 +293,15 @@ class base_html_playwright(Fetcher): | ||||
|  | ||||
|         # allow per-watch proxy selection override | ||||
|         if proxy_override: | ||||
|             self.proxy = {'server': proxy_override} | ||||
|             # https://playwright.dev/docs/network#http-proxy | ||||
|             from urllib.parse import urlparse | ||||
|             parsed = urlparse(proxy_override) | ||||
|             proxy_url = "{}://{}:{}".format(parsed.scheme, parsed.hostname, parsed.port) | ||||
|             self.proxy = {'server': proxy_url} | ||||
|             if parsed.username: | ||||
|                 self.proxy['username'] = parsed.username | ||||
|             if parsed.password: | ||||
|                 self.proxy['password'] = parsed.password | ||||
|  | ||||
|     def run(self, | ||||
|             url, | ||||
| @@ -319,40 +353,53 @@ class base_html_playwright(Fetcher): | ||||
|                 with page.expect_navigation(): | ||||
|                     response = page.goto(url, wait_until='load') | ||||
|  | ||||
|                 if self.webdriver_js_execute_code is not None: | ||||
|                     page.evaluate(self.webdriver_js_execute_code) | ||||
|  | ||||
|             except playwright._impl._api_types.TimeoutError as e: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 # This can be ok, we will try to grab what we could retrieve | ||||
|                 pass | ||||
|  | ||||
|             except Exception as e: | ||||
|                 print ("other exception when page.goto") | ||||
|                 print (str(e)) | ||||
|                 print("other exception when page.goto") | ||||
|                 print(str(e)) | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 raise PageUnloadable(url=url, status_code=None) | ||||
|                 raise PageUnloadable(url=url, status_code=None, message=e.message) | ||||
|  | ||||
|             if response is None: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 print ("response object was none") | ||||
|                 print("response object was none") | ||||
|                 raise EmptyReply(url=url, status_code=None) | ||||
|  | ||||
|             # Bug 2(?) Set the viewport size AFTER loading the page | ||||
|             page.set_viewport_size({"width": 1280, "height": 1024})             | ||||
|             page.set_viewport_size({"width": 1280, "height": 1024}) | ||||
|             extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay | ||||
|             time.sleep(extra_wait) | ||||
|  | ||||
|             if self.webdriver_js_execute_code is not None: | ||||
|                 try: | ||||
|                     page.evaluate(self.webdriver_js_execute_code) | ||||
|                 except Exception as e: | ||||
|                     # Is it possible to get a screenshot? | ||||
|                     error_screenshot = False | ||||
|                     try: | ||||
|                         page.screenshot(type='jpeg', | ||||
|                                         clip={'x': 1.0, 'y': 1.0, 'width': 1280, 'height': 1024}, | ||||
|                                         quality=1) | ||||
|  | ||||
|                         # The actual screenshot | ||||
|                         error_screenshot = page.screenshot(type='jpeg', | ||||
|                                                            full_page=True, | ||||
|                                                            quality=int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72))) | ||||
|                     except Exception as s: | ||||
|                         pass | ||||
|  | ||||
|                     raise JSActionExceptions(status_code=response.status, screenshot=error_screenshot, message=str(e), url=url) | ||||
|  | ||||
|             self.content = page.content() | ||||
|             self.status_code = response.status | ||||
|  | ||||
|             if len(self.content.strip()) == 0: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 print ("Content was empty") | ||||
|                 raise EmptyReply(url=url, status_code=None) | ||||
|              | ||||
|             self.headers = response.all_headers() | ||||
|  | ||||
|             if current_css_filter is not None: | ||||
| @@ -379,9 +426,17 @@ class base_html_playwright(Fetcher): | ||||
|                 browser.close() | ||||
|                 raise ScreenshotUnavailable(url=url, status_code=None) | ||||
|  | ||||
|             if len(self.content.strip()) == 0: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 print("Content was empty") | ||||
|                 raise EmptyReply(url=url, status_code=None, screenshot=self.screenshot) | ||||
|  | ||||
|             context.close() | ||||
|             browser.close() | ||||
|  | ||||
|             if not ignore_status_codes and self.status_code!=200: | ||||
|                 raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, page_html=self.content, screenshot=self.screenshot) | ||||
|  | ||||
| class base_html_webdriver(Fetcher): | ||||
|     if os.getenv("WEBDRIVER_URL"): | ||||
| @@ -509,7 +564,7 @@ class html_requests(Fetcher): | ||||
|             ignore_status_codes=False, | ||||
|             current_css_filter=None): | ||||
|  | ||||
|         proxies={} | ||||
|         proxies = {} | ||||
|  | ||||
|         # Allows override the proxy on a per-request basis | ||||
|         if self.proxy_override: | ||||
| @@ -537,10 +592,14 @@ class html_requests(Fetcher): | ||||
|             if encoding: | ||||
|                 r.encoding = encoding | ||||
|  | ||||
|         if not r.content or not len(r.content): | ||||
|             raise EmptyReply(url=url, status_code=r.status_code) | ||||
|  | ||||
|         # @todo test this | ||||
|         # @todo maybe you really want to test zero-byte return pages? | ||||
|         if (not ignore_status_codes and not r) or not r.content or not len(r.content): | ||||
|             raise EmptyReply(url=url, status_code=r.status_code) | ||||
|         if r.status_code != 200 and not ignore_status_codes: | ||||
|             # maybe check with content works? | ||||
|             raise Non200ErrorCodeReceived(url=url, status_code=r.status_code, page_html=r.text) | ||||
|  | ||||
|         self.status_code = r.status_code | ||||
|         self.content = r.text | ||||
|   | ||||
| @@ -13,6 +13,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
| # Some common stuff here that can be moved to a base class | ||||
| # (set_proxy_from_list) | ||||
| class perform_site_check(): | ||||
|     screenshot = None | ||||
|     xpath_data = None | ||||
|  | ||||
|     def __init__(self, *args, datastore, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
| @@ -94,7 +96,7 @@ class perform_site_check(): | ||||
|         url = self.datastore.get_val(uuid, 'url') | ||||
|         request_body = self.datastore.get_val(uuid, 'body') | ||||
|         request_method = self.datastore.get_val(uuid, 'method') | ||||
|         ignore_status_code = self.datastore.get_val(uuid, 'ignore_status_codes') | ||||
|         ignore_status_codes = self.datastore.data['watching'][uuid].get('ignore_status_codes', False) | ||||
|  | ||||
|         # source: support | ||||
|         is_source = False | ||||
| @@ -124,9 +126,12 @@ class perform_site_check(): | ||||
|         if watch['webdriver_js_execute_code'] is not None and watch['webdriver_js_execute_code'].strip(): | ||||
|             fetcher.webdriver_js_execute_code = watch['webdriver_js_execute_code'] | ||||
|  | ||||
|         fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_code, watch['css_filter']) | ||||
|         fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_codes, watch['css_filter']) | ||||
|         fetcher.quit() | ||||
|  | ||||
|         self.screenshot = fetcher.screenshot | ||||
|         self.xpath_data = fetcher.xpath_data | ||||
|  | ||||
|         # Fetching complete, now filters | ||||
|         # @todo move to class / maybe inside of fetcher abstract base? | ||||
|  | ||||
| @@ -210,7 +215,7 @@ class perform_site_check(): | ||||
|         # Treat pages with no renderable text content as a change? No by default | ||||
|         empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False) | ||||
|         if not is_json and not empty_pages_are_a_change and len(stripped_text_from_html.strip()) == 0: | ||||
|             raise content_fetcher.ReplyWithContentButNoText(url=url, status_code=200) | ||||
|             raise content_fetcher.ReplyWithContentButNoText(url=url, status_code=fetcher.get_last_status_code(), screenshot=screenshot) | ||||
|  | ||||
|         # We rely on the actual text in the html output.. many sites have random script vars etc, | ||||
|         # in the future we'll implement other mechanisms. | ||||
| @@ -312,4 +317,4 @@ class perform_site_check(): | ||||
|         if not watch.get('previous_md5'): | ||||
|             watch['previous_md5'] = fetched_md5 | ||||
|  | ||||
|         return changed_detected, update_obj, text_content_before_ignored_filter, fetcher.screenshot, fetcher.xpath_data | ||||
|         return changed_detected, update_obj, text_content_before_ignored_filter | ||||
|   | ||||
| @@ -350,11 +350,13 @@ class watchForm(commonSettingsForm): | ||||
|     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_and_preview_button = SubmitField('Save & Preview', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|  | ||||
|     proxy = RadioField('Proxy') | ||||
|     filter_failure_notification_send = BooleanField( | ||||
|         'Send a notification when the filter can no longer be found on the page', default=False) | ||||
|  | ||||
|     notification_use_default = BooleanField('Use default/system notification settings', default=True) | ||||
|  | ||||
|     def validate(self, **kwargs): | ||||
|         if not super().validate(): | ||||
|             return False | ||||
| @@ -384,7 +386,6 @@ class globalSettingsApplicationForm(commonSettingsForm): | ||||
|     global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)]) | ||||
|     global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()]) | ||||
|     ignore_whitespace = BooleanField('Ignore whitespace') | ||||
|     real_browser_save_screenshot = BooleanField('Save last screenshot when using Chrome?') | ||||
|     removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|     empty_pages_are_a_change =  BooleanField('Treat empty pages as a change?', default=False) | ||||
|     render_anchor_tag_content = BooleanField('Render anchor tag content', default=False) | ||||
|   | ||||
| @@ -42,7 +42,6 @@ class model(dict): | ||||
|                     'notification_title': default_notification_title, | ||||
|                     'notification_body': default_notification_body, | ||||
|                     'notification_format': default_notification_format, | ||||
|                     'real_browser_save_screenshot': True, | ||||
|                     'schema_version' : 0, | ||||
|                     'webdriver_delay': None  # Extra delay in seconds before extracting text | ||||
|                 } | ||||
|   | ||||
| @@ -19,7 +19,6 @@ class model(dict): | ||||
|             'url': None, | ||||
|             'tag': None, | ||||
|             'last_checked': 0, | ||||
|             'last_changed': 0, | ||||
|             'paused': False, | ||||
|             'last_viewed': 0,  # history key value of the last viewed via the [diff] link | ||||
|             #'newest_history_key': 0, | ||||
| @@ -36,7 +35,10 @@ class model(dict): | ||||
|             'notification_title': default_notification_title, | ||||
|             'notification_body': default_notification_body, | ||||
|             'notification_format': default_notification_format, | ||||
|             'notification_use_default': True, # Use default for new | ||||
|             'notification_muted': False, | ||||
|             'css_filter': '', | ||||
|             'last_error': False, | ||||
|             'extract_text': [],  # Extract text by regex after filters | ||||
|             'subtractive_selectors': [], | ||||
|             'trigger_text': [],  # List of text or regex to wait for until a change is detected | ||||
| @@ -57,11 +59,11 @@ class model(dict): | ||||
|     jitter_seconds = 0 | ||||
|  | ||||
|     def __init__(self, *arg, **kw): | ||||
|         import uuid | ||||
|  | ||||
|         self.update(self.__base_config) | ||||
|         self.__datastore_path = kw['datastore_path'] | ||||
|  | ||||
|         self['uuid'] = str(uuid.uuid4()) | ||||
|         self['uuid'] = str(uuid_builder.uuid4()) | ||||
|  | ||||
|         del kw['datastore_path'] | ||||
|  | ||||
| @@ -69,7 +71,10 @@ class model(dict): | ||||
|             self.update(kw['default']) | ||||
|             del kw['default'] | ||||
|  | ||||
|         # goes at the end so we update the default object with the initialiser | ||||
|         # Be sure the cached timestamp is ready | ||||
|         bump = self.history | ||||
|  | ||||
|         # Goes at the end so we update the default object with the initialiser | ||||
|         super(model, self).__init__(*arg, **kw) | ||||
|  | ||||
|     @property | ||||
| @@ -79,6 +84,28 @@ class model(dict): | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     def ensure_data_dir_exists(self): | ||||
|         target_path = os.path.join(self.__datastore_path, self['uuid']) | ||||
|         if not os.path.isdir(target_path): | ||||
|             print ("> Creating data dir {}".format(target_path)) | ||||
|             os.mkdir(target_path) | ||||
|  | ||||
|     @property | ||||
|     def label(self): | ||||
|         # Used for sorting | ||||
|         if self['title']: | ||||
|             return self['title'] | ||||
|         return self['url'] | ||||
|  | ||||
|     @property | ||||
|     def last_changed(self): | ||||
|         # last_changed will be the newest snapshot, but when we have just one snapshot, it should be 0 | ||||
|         if self.__history_n <= 1: | ||||
|             return 0 | ||||
|         if self.__newest_history_key: | ||||
|             return int(self.__newest_history_key) | ||||
|         return 0 | ||||
|  | ||||
|     @property | ||||
|     def history_n(self): | ||||
|         return self.__history_n | ||||
| @@ -121,19 +148,15 @@ class model(dict): | ||||
|         bump = self.history | ||||
|         return self.__newest_history_key | ||||
|  | ||||
|  | ||||
|     # Save some text file to the appropriate path and bump the history | ||||
|     # result_obj from fetch_site_status.run() | ||||
|     def save_history_text(self, contents, timestamp): | ||||
|         import uuid | ||||
|         from os import mkdir, path, unlink | ||||
|         import logging | ||||
|  | ||||
|         output_path = "{}/{}".format(self.__datastore_path, self['uuid']) | ||||
|  | ||||
|         # Incase the operator deleted it, check and create. | ||||
|         if not os.path.isdir(output_path): | ||||
|             mkdir(output_path) | ||||
|         self.ensure_data_dir_exists() | ||||
|  | ||||
|         snapshot_fname = "{}/{}.stripped.txt".format(output_path, uuid.uuid4()) | ||||
|         logging.debug("Saving history text {}".format(snapshot_fname)) | ||||
| @@ -171,7 +194,7 @@ class model(dict): | ||||
|         return seconds | ||||
|  | ||||
|     # Iterate over all history texts and see if something new exists | ||||
|     def lines_contain_something_unique_compared_to_history(self, lines=[]): | ||||
|     def lines_contain_something_unique_compared_to_history(self, lines: list): | ||||
|         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.. | ||||
| @@ -183,3 +206,51 @@ class model(dict): | ||||
|         # Check that everything in local_lines(new stuff) already exists in existing_history - it should | ||||
|         # if not, something new happened | ||||
|         return not local_lines.issubset(existing_history) | ||||
|  | ||||
|     def get_screenshot(self): | ||||
|         fname = os.path.join(self.__datastore_path, self['uuid'], "last-screenshot.png") | ||||
|         if os.path.isfile(fname): | ||||
|             return fname | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     def __get_file_ctime(self, filename): | ||||
|         fname = os.path.join(self.__datastore_path, self['uuid'], filename) | ||||
|         if os.path.isfile(fname): | ||||
|             return int(os.path.getmtime(fname)) | ||||
|         return False | ||||
|  | ||||
|     @property | ||||
|     def error_text_ctime(self): | ||||
|         return self.__get_file_ctime('last-error.txt') | ||||
|  | ||||
|     @property | ||||
|     def snapshot_text_ctime(self): | ||||
|         if self.history_n==0: | ||||
|             return False | ||||
|  | ||||
|         timestamp = list(self.history.keys())[-1] | ||||
|         return int(timestamp) | ||||
|  | ||||
|     @property | ||||
|     def snapshot_screenshot_ctime(self): | ||||
|         return self.__get_file_ctime('last-screenshot.png') | ||||
|  | ||||
|     @property | ||||
|     def snapshot_error_screenshot_ctime(self): | ||||
|         return self.__get_file_ctime('last-error-screenshot.png') | ||||
|  | ||||
|     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.__datastore_path, self['uuid'], "last-error.txt") | ||||
|         if os.path.isfile(fname): | ||||
|             with open(fname, 'r') as f: | ||||
|                 return f.read() | ||||
|         return False | ||||
|  | ||||
|     def get_error_snapshot(self): | ||||
|         """Return path to the screenshot that resulted in a non-200 error""" | ||||
|         fname = os.path.join(self.__datastore_path, self['uuid'], "last-error-screenshot.png") | ||||
|         if os.path.isfile(fname): | ||||
|             return fname | ||||
|         return False | ||||
|   | ||||
| @@ -32,16 +32,20 @@ docker run -d --name $$-test_selenium  -p 4444:4444 --rm --shm-size="2g"  seleni | ||||
| sleep 5 | ||||
| export WEBDRIVER_URL=http://localhost:4444/wd/hub | ||||
| pytest tests/fetchers/test_content.py | ||||
| pytest tests/test_errorhandling.py | ||||
| unset WEBDRIVER_URL | ||||
| docker kill $$-test_selenium | ||||
|  | ||||
| echo "TESTING WEBDRIVER FETCH > PLAYWRIGHT/BROWSERLESS..." | ||||
| # Not all platforms support playwright (not ARM/rPI), so it's not packaged in requirements.txt | ||||
| pip3 install playwright~=1.22 | ||||
| pip3 install playwright~=1.24 | ||||
| docker run -d --name $$-test_browserless -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm  -p 3000:3000  --shm-size="2g"  browserless/chrome:1.53-chrome-stable | ||||
| # takes a while to spin up | ||||
| sleep 5 | ||||
| export PLAYWRIGHT_DRIVER_URL=ws://127.0.0.1:3000 | ||||
| pytest tests/fetchers/test_content.py | ||||
| pytest tests/test_errorhandling.py | ||||
| pytest tests/visualselector/test_fetch_data.py | ||||
|  | ||||
| unset PLAYWRIGHT_DRIVER_URL | ||||
| docker kill $$-test_browserless | ||||
							
								
								
									
										42
									
								
								changedetectionio/static/images/bell-off.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								changedetectionio/static/images/bell-off.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg | ||||
|    width="15" | ||||
|    height="16.363636" | ||||
|    viewBox="0 0 15 16.363636" | ||||
|    version="1.1" | ||||
|    id="svg4" | ||||
|    sodipodi:docname="bell-off.svg" | ||||
|    inkscape:version="1.1.1 (1:1.1+202109281949+c3084ef5ed)" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <sodipodi:namedview | ||||
|      id="namedview5" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#666666" | ||||
|      borderopacity="1.0" | ||||
|      inkscape:pageshadow="2" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pagecheckerboard="0" | ||||
|      showgrid="false" | ||||
|      fit-margin-top="0" | ||||
|      fit-margin-left="0" | ||||
|      fit-margin-right="0" | ||||
|      fit-margin-bottom="0" | ||||
|      inkscape:zoom="28.416667" | ||||
|      inkscape:cx="-0.59824046" | ||||
|      inkscape:cy="12" | ||||
|      inkscape:window-width="1554" | ||||
|      inkscape:window-height="896" | ||||
|      inkscape:window-x="2095" | ||||
|      inkscape:window-y="107" | ||||
|      inkscape:window-maximized="0" | ||||
|      inkscape:current-layer="svg4" /> | ||||
|   <defs | ||||
|      id="defs8" /> | ||||
|   <path | ||||
|      d="m 14.318182,11.762045 v 1.1925 H 5.4102273 L 11.849318,7.1140909 C 12.234545,9.1561364 12.54,11.181818 14.318182,11.762045 Z m -6.7984093,4.601591 c 1.0759091,0 2.0256823,-0.955909 2.0256823,-2.045454 H 5.4545455 c 0,1.089545 0.9879545,2.045454 2.0652272,2.045454 z M 15,2.8622727 0.9177273,15.636136 0,14.627045 l 1.8443182,-1.6725 h -1.1625 v -1.1925 C 4.0070455,10.677273 2.1784091,4.5388636 5.3611364,2.6897727 5.8009091,2.4347727 6.0709091,1.9609091 6.0702273,1.4488636 v -0.00205 C 6.0702273,0.64772727 6.7104545,0 7.5,0 8.2895455,0 8.9297727,0.64772727 8.9297727,1.4468182 v 0.00205 C 8.9290909,1.9602319 9.199773,2.4354591 9.638864,2.6897773 10.364318,3.111141 10.827273,3.7568228 11.1525,4.5129591 L 14.085682,1.8531818 Z M 6.8181818,1.3636364 C 6.8181818,1.74 7.1236364,2.0454545 7.5,2.0454545 7.8763636,2.0454545 8.1818182,1.74 8.1818182,1.3636364 8.1818182,0.98795455 7.8763636,0.68181818 7.5,0.68181818 c -0.3763636,0 -0.6818182,0.30613637 -0.6818182,0.68181822 z" | ||||
|      id="path2" | ||||
|      style="fill:#f8321b;stroke-width:0.681818;fill-opacity:1" /> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 2.1 KiB | 
| @@ -10,7 +10,13 @@ $(document).ready(function () { | ||||
|         if (hash_name === '#screenshot') { | ||||
|             $("img#screenshot-img").attr('src', screenshot_url); | ||||
|             $("#settings").hide(); | ||||
|         } else { | ||||
|         } else if (hash_name === '#error-screenshot') { | ||||
|             $("img#error-screenshot-img").attr('src', error_screenshot_url); | ||||
|             $("#settings").hide(); | ||||
|         } | ||||
|  | ||||
|  | ||||
|         else { | ||||
|             $("#settings").show(); | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -1,51 +1,44 @@ | ||||
| // Rewrite this is a plugin.. is all this JS really 'worth it?' | ||||
|  | ||||
|  | ||||
| if(!window.location.hash) { | ||||
|   var tab=document.querySelectorAll("#default-tab a"); | ||||
|   tab[0].click(); | ||||
| } | ||||
|  | ||||
| window.addEventListener('hashchange', function() { | ||||
|   var tabs = document.getElementsByClassName('active'); | ||||
|   while (tabs[0]) { | ||||
|     tabs[0].classList.remove('active') | ||||
|   } | ||||
|   set_active_tab(); | ||||
| window.addEventListener('hashchange', function () { | ||||
|     var tabs = document.getElementsByClassName('active'); | ||||
|     while (tabs[0]) { | ||||
|         tabs[0].classList.remove('active') | ||||
|     } | ||||
|     set_active_tab(); | ||||
| }, false); | ||||
|  | ||||
| var has_errors=document.querySelectorAll(".messages .error"); | ||||
| var has_errors = document.querySelectorAll(".messages .error"); | ||||
| if (!has_errors.length) { | ||||
|     if (document.location.hash == "" ) { | ||||
|         document.location.hash = "#general"; | ||||
|         document.getElementById("default-tab").className = "active"; | ||||
|     if (document.location.hash == "") { | ||||
|         document.querySelector(".tabs ul li:first-child a").click(); | ||||
|     } else { | ||||
|         set_active_tab(); | ||||
|     } | ||||
| } else { | ||||
|   focus_error_tab(); | ||||
|     focus_error_tab(); | ||||
| } | ||||
|  | ||||
| function set_active_tab() { | ||||
|   var tab=document.querySelectorAll("a[href='"+location.hash+"']"); | ||||
|   if (tab.length) { | ||||
|     tab[0].parentElement.className="active"; | ||||
|   } | ||||
|     var tab = document.querySelectorAll("a[href='" + location.hash + "']"); | ||||
|     if (tab.length) { | ||||
|         tab[0].parentElement.className = "active"; | ||||
|     } | ||||
|     // hash could move the page down | ||||
|     window.scrollTo(0, 0); | ||||
| } | ||||
|  | ||||
| function focus_error_tab() { | ||||
|   // time to use jquery or vuejs really, | ||||
|   // activate the tab with the error | ||||
|     var tabs = document.querySelectorAll('.tabs li a'),i; | ||||
|     // time to use jquery or vuejs really, | ||||
|     // activate the tab with the error | ||||
|     var tabs = document.querySelectorAll('.tabs li a'), i; | ||||
|     for (i = 0; i < tabs.length; ++i) { | ||||
|       var tab_name=tabs[i].hash.replace('#',''); | ||||
|       var pane_errors=document.querySelectorAll('#'+tab_name+' .error') | ||||
|       if (pane_errors.length) { | ||||
|         document.location.hash = '#'+tab_name; | ||||
|         return true; | ||||
|       } | ||||
|         var tab_name = tabs[i].hash.replace('#', ''); | ||||
|         var pane_errors = document.querySelectorAll('#' + tab_name + ' .error') | ||||
|         if (pane_errors.length) { | ||||
|             document.location.hash = '#' + tab_name; | ||||
|             return true; | ||||
|         } | ||||
|     } | ||||
|     return false; | ||||
| } | ||||
|   | ||||
| @@ -22,5 +22,18 @@ $(function () { | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|     // checkboxes - check all | ||||
|     $("#check-all").click(function (e) { | ||||
|         $('input[type=checkbox]').not(this).prop('checked', this.checked); | ||||
|     }); | ||||
|     // checkboxes - show/hide buttons | ||||
|     $("input[type=checkbox]").click(function (e) { | ||||
|         if ($('input[type=checkbox]:checked').length) { | ||||
|             $('#checkbox-operations').slideDown(); | ||||
|         } else { | ||||
|             $('#checkbox-operations').slideUp(); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| $(document).ready(function() { | ||||
|     function toggle() { | ||||
| $(document).ready(function () { | ||||
|     function toggle_fetch_backend() { | ||||
|         if ($('input[name="fetch_backend"]:checked').val() == 'html_webdriver') { | ||||
|             if(playwright_enabled) { | ||||
|             if (playwright_enabled) { | ||||
|                 // playwright supports headers, so hide everything else | ||||
|                 // See #664 | ||||
|                 $('#requests-override-options #request-method').hide(); | ||||
| @@ -13,12 +13,8 @@ $(document).ready(function() { | ||||
|                 // selenium/webdriver doesnt support anything afaik, hide it all | ||||
|                 $('#requests-override-options').hide(); | ||||
|             } | ||||
|  | ||||
|  | ||||
|             $('#webdriver-override-options').show(); | ||||
|  | ||||
|         } else { | ||||
|  | ||||
|             $('#requests-override-options').show(); | ||||
|             $('#requests-override-options *:hidden').show(); | ||||
|             $('#webdriver-override-options').hide(); | ||||
| @@ -26,8 +22,27 @@ $(document).ready(function() { | ||||
|     } | ||||
|  | ||||
|     $('input[name="fetch_backend"]').click(function (e) { | ||||
|         toggle(); | ||||
|         toggle_fetch_backend(); | ||||
|     }); | ||||
|     toggle(); | ||||
|     toggle_fetch_backend(); | ||||
|  | ||||
|     function toggle_default_notifications() { | ||||
|         var n=$('#notification_urls, #notification_title, #notification_body, #notification_format'); | ||||
|         if ($('#notification_use_default').is(':checked')) { | ||||
|             $('#notification-field-group').fadeOut(); | ||||
|             $(n).each(function (e) { | ||||
|                 $(this).attr('readonly', true); | ||||
|             }); | ||||
|         } else { | ||||
|             $('#notification-field-group').show(); | ||||
|             $(n).each(function (e) { | ||||
|                 $(this).attr('readonly', false); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     $('#notification_use_default').click(function (e) { | ||||
|         toggle_default_notifications(); | ||||
|     }); | ||||
|     toggle_default_notifications(); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										26
									
								
								changedetectionio/static/styles/parts/_arrows.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								changedetectionio/static/styles/parts/_arrows.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| .arrow { | ||||
|   border: solid #1b98f8; | ||||
|   border-width: 0 2px 2px 0; | ||||
|   display: inline-block; | ||||
|   padding: 3px; | ||||
|  | ||||
|   &.right { | ||||
|     transform: rotate(-45deg); | ||||
|     -webkit-transform: rotate(-45deg); | ||||
|   } | ||||
|  | ||||
|   &.left { | ||||
|     transform: rotate(135deg); | ||||
|     -webkit-transform: rotate(135deg); | ||||
|   } | ||||
|  | ||||
|   &.up, &.asc { | ||||
|     transform: rotate(-135deg); | ||||
|     -webkit-transform: rotate(-135deg); | ||||
|   } | ||||
|  | ||||
|   &.down, &.desc { | ||||
|     transform: rotate(45deg); | ||||
|     -webkit-transform: rotate(45deg); | ||||
|   } | ||||
| } | ||||
| @@ -4,6 +4,24 @@ | ||||
|  * nvm use v14.18.1 && npm install && npm run build | ||||
|  * or npm run watch | ||||
|  */ | ||||
| .arrow { | ||||
|   border: solid #1b98f8; | ||||
|   border-width: 0 2px 2px 0; | ||||
|   display: inline-block; | ||||
|   padding: 3px; } | ||||
|   .arrow.right { | ||||
|     transform: rotate(-45deg); | ||||
|     -webkit-transform: rotate(-45deg); } | ||||
|   .arrow.left { | ||||
|     transform: rotate(135deg); | ||||
|     -webkit-transform: rotate(135deg); } | ||||
|   .arrow.up, .arrow.asc { | ||||
|     transform: rotate(-135deg); | ||||
|     -webkit-transform: rotate(-135deg); } | ||||
|   .arrow.down, .arrow.desc { | ||||
|     transform: rotate(45deg); | ||||
|     -webkit-transform: rotate(45deg); } | ||||
|  | ||||
| body { | ||||
|   color: #333; | ||||
|   background: #262626; } | ||||
| @@ -53,6 +71,12 @@ code { | ||||
|     white-space: normal; } | ||||
|   .watch-table th { | ||||
|     white-space: nowrap; } | ||||
|     .watch-table th a { | ||||
|       font-weight: normal; } | ||||
|       .watch-table th a.active { | ||||
|         font-weight: bolder; } | ||||
|       .watch-table th a.inactive .arrow { | ||||
|         display: none; } | ||||
|   .watch-table .title-col a[target="_blank"]::after, .watch-table .current-diff-url::after { | ||||
|     content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==); | ||||
|     margin: 0 3px 0 5px; } | ||||
| @@ -103,24 +127,6 @@ body:after, body:before { | ||||
|   -webkit-clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); | ||||
|   clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); } | ||||
|  | ||||
| .arrow { | ||||
|   border: solid black; | ||||
|   border-width: 0 3px 3px 0; | ||||
|   display: inline-block; | ||||
|   padding: 3px; } | ||||
|   .arrow.right { | ||||
|     transform: rotate(-45deg); | ||||
|     -webkit-transform: rotate(-45deg); } | ||||
|   .arrow.left { | ||||
|     transform: rotate(135deg); | ||||
|     -webkit-transform: rotate(135deg); } | ||||
|   .arrow.up { | ||||
|     transform: rotate(-135deg); | ||||
|     -webkit-transform: rotate(-135deg); } | ||||
|   .arrow.down { | ||||
|     transform: rotate(45deg); | ||||
|     -webkit-transform: rotate(45deg); } | ||||
|  | ||||
| .button-small { | ||||
|   font-size: 85%; } | ||||
|  | ||||
| @@ -271,11 +277,15 @@ footer { | ||||
| #new-version-text a { | ||||
|   color: #e07171; } | ||||
|  | ||||
| .paused-state.state-False img { | ||||
|   opacity: 0.2; } | ||||
|  | ||||
| .paused-state.state-False:hover img { | ||||
|   opacity: 0.8; } | ||||
| .watch-controls { | ||||
|   /* default */ } | ||||
|   .watch-controls .state-on img { | ||||
|     opacity: 0.8; } | ||||
|   .watch-controls img { | ||||
|     opacity: 0.2; } | ||||
|   .watch-controls img:hover { | ||||
|     transition: opacity 0.3s; | ||||
|     opacity: 0.8; } | ||||
|  | ||||
| .monospaced-textarea textarea { | ||||
|   width: 100%; | ||||
| @@ -535,3 +545,23 @@ ul { | ||||
|   100% { | ||||
|     -webkit-transform: rotate(360deg); | ||||
|     transform: rotate(360deg); } } | ||||
|  | ||||
| .snapshot-age { | ||||
|   padding: 4px; | ||||
|   background-color: #dfdfdf; | ||||
|   border-radius: 3px; | ||||
|   font-weight: bold; | ||||
|   margin-bottom: 4px; } | ||||
|   .snapshot-age.error { | ||||
|     background-color: #ff0000; | ||||
|     color: #fff; } | ||||
|  | ||||
| #checkbox-operations { | ||||
|   background: rgba(0, 0, 0, 0.05); | ||||
|   padding: 1em; | ||||
|   border-radius: 10px; | ||||
|   margin-bottom: 1em; | ||||
|   display: none; } | ||||
|  | ||||
| .checkbox-uuid > * { | ||||
|   vertical-align: middle; } | ||||
|   | ||||
| @@ -4,6 +4,8 @@ | ||||
|  * nvm use v14.18.1 && npm install && npm run build | ||||
|  * or npm run watch | ||||
|  */ | ||||
| @import "parts/_arrows.scss"; | ||||
|  | ||||
| body { | ||||
|   color: #333; | ||||
|   background: #262626; | ||||
| @@ -68,6 +70,17 @@ code { | ||||
|  | ||||
|   th { | ||||
|     white-space: nowrap; | ||||
|     a { | ||||
|       font-weight: normal; | ||||
|       &.active { | ||||
|         font-weight: bolder; | ||||
|       } | ||||
|       &.inactive { | ||||
|         .arrow { | ||||
|           display: none; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .title-col a[target="_blank"]::after, .current-diff-url::after { | ||||
| @@ -137,29 +150,6 @@ body:after, body:before { | ||||
|   clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%) | ||||
| } | ||||
|  | ||||
| .arrow { | ||||
|   border: solid black; | ||||
|   border-width: 0 3px 3px 0; | ||||
|   display: inline-block; | ||||
|   padding: 3px; | ||||
|     &.right { | ||||
|       transform: rotate(-45deg); | ||||
|       -webkit-transform: rotate(-45deg); | ||||
|     } | ||||
|     &.left { | ||||
|       transform: rotate(135deg); | ||||
|       -webkit-transform: rotate(135deg); | ||||
|     } | ||||
|     &.up { | ||||
|       transform: rotate(-135deg); | ||||
|       -webkit-transform: rotate(-135deg); | ||||
|     } | ||||
|     &.down { | ||||
|       transform: rotate(45deg); | ||||
|       -webkit-transform: rotate(45deg); | ||||
|     } | ||||
| } | ||||
|  | ||||
| .button-small { | ||||
|   font-size: 85%; | ||||
| } | ||||
| @@ -362,14 +352,25 @@ footer { | ||||
|   color: #e07171; | ||||
| } | ||||
|  | ||||
| .paused-state { | ||||
|   &.state-False img { | ||||
| .watch-controls { | ||||
|   .state-on { | ||||
|     img { | ||||
|       opacity: 0.8; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /* default */ | ||||
|   img { | ||||
|     opacity: 0.2; | ||||
|   } | ||||
|  | ||||
|   &.state-False:hover img { | ||||
|     opacity: 0.8; | ||||
|   img { | ||||
|     &:hover { | ||||
|       transition: opacity 0.3s; | ||||
|       opacity: 0.8; | ||||
|     } | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| .monospaced-textarea { | ||||
| @@ -501,6 +502,7 @@ and also iPads specifically. | ||||
|         vertical-align: middle; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .last-checked::before { | ||||
|       color: #555; | ||||
|       content: "Last Checked "; | ||||
| @@ -760,3 +762,27 @@ ul { | ||||
|   } | ||||
| } | ||||
|  | ||||
| .snapshot-age { | ||||
|   padding: 4px; | ||||
|   background-color: #dfdfdf; | ||||
|   border-radius: 3px; | ||||
|   font-weight: bold; | ||||
|   margin-bottom: 4px; | ||||
|   &.error { | ||||
|     background-color: #ff0000; | ||||
|     color: #fff; | ||||
|   } | ||||
| } | ||||
|  | ||||
| #checkbox-operations { | ||||
|   background: rgba(0, 0, 0, 0.05); | ||||
|   padding: 1em; | ||||
|   border-radius: 10px; | ||||
|   margin-bottom: 1em; | ||||
|   display: none; | ||||
| } | ||||
| .checkbox-uuid { | ||||
|   > * { | ||||
|     vertical-align: middle; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import threading | ||||
| import time | ||||
| import uuid as uuid_builder | ||||
| from copy import deepcopy | ||||
| from os import mkdir, path, unlink | ||||
| from os import path, unlink | ||||
| from threading import Lock | ||||
| import re | ||||
| import requests | ||||
| @@ -254,7 +254,6 @@ class ChangeDetectionStore: | ||||
|  | ||||
|         self.__data['watching'][uuid].update( | ||||
|             {'last_checked': 0, | ||||
|              'last_changed': 0, | ||||
|              'last_viewed': 0, | ||||
|              'previous_md5': False, | ||||
|              'last_notification_error': False, | ||||
| @@ -325,25 +324,12 @@ class ChangeDetectionStore: | ||||
|             new_watch.update(apply_extras) | ||||
|             self.__data['watching'][new_uuid]=new_watch | ||||
|  | ||||
|         # Get the directory ready | ||||
|         output_path = "{}/{}".format(self.datastore_path, new_uuid) | ||||
|         try: | ||||
|             mkdir(output_path) | ||||
|         except FileExistsError: | ||||
|             print(output_path, "already exists.") | ||||
|         self.__data['watching'][new_uuid].ensure_data_dir_exists() | ||||
|  | ||||
|         if write_to_disk_now: | ||||
|             self.sync_to_json() | ||||
|         return new_uuid | ||||
|  | ||||
|     def get_screenshot(self, watch_uuid): | ||||
|         output_path = "{}/{}".format(self.datastore_path, watch_uuid) | ||||
|         fname = "{}/last-screenshot.png".format(output_path) | ||||
|         if path.isfile(fname): | ||||
|             return fname | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     def visualselector_data_is_ready(self, watch_uuid): | ||||
|         output_path = "{}/{}".format(self.datastore_path, watch_uuid) | ||||
|         screenshot_filename = "{}/last-screenshot.png".format(output_path) | ||||
| @@ -354,17 +340,38 @@ class ChangeDetectionStore: | ||||
|         return False | ||||
|  | ||||
|     # Save as PNG, PNG is larger but better for doing visual diff in the future | ||||
|     def save_screenshot(self, watch_uuid, screenshot: bytes): | ||||
|         output_path = "{}/{}".format(self.datastore_path, watch_uuid) | ||||
|         fname = "{}/last-screenshot.png".format(output_path) | ||||
|         with open(fname, 'wb') as f: | ||||
|     def save_screenshot(self, watch_uuid, screenshot: bytes, as_error=False): | ||||
|         if not self.data['watching'].get(watch_uuid): | ||||
|             return | ||||
|  | ||||
|         if as_error: | ||||
|             target_path = os.path.join(self.datastore_path, watch_uuid, "last-error-screenshot.png") | ||||
|         else: | ||||
|             target_path = os.path.join(self.datastore_path, watch_uuid, "last-screenshot.png") | ||||
|  | ||||
|         self.data['watching'][watch_uuid].ensure_data_dir_exists() | ||||
|  | ||||
|         with open(target_path, 'wb') as f: | ||||
|             f.write(screenshot) | ||||
|             f.close() | ||||
|  | ||||
|     def save_xpath_data(self, watch_uuid, data): | ||||
|         output_path = "{}/{}".format(self.datastore_path, watch_uuid) | ||||
|         fname = "{}/elements.json".format(output_path) | ||||
|         with open(fname, 'w') as f: | ||||
|     def save_error_text(self, watch_uuid, contents): | ||||
|         if not self.data['watching'].get(watch_uuid): | ||||
|             return | ||||
|         target_path = os.path.join(self.datastore_path, watch_uuid, "last-error.txt") | ||||
|  | ||||
|         with open(target_path, 'w') as f: | ||||
|             f.write(contents) | ||||
|  | ||||
|     def save_xpath_data(self, watch_uuid, data, as_error=False): | ||||
|         if not self.data['watching'].get(watch_uuid): | ||||
|             return | ||||
|         if as_error: | ||||
|             target_path = os.path.join(self.datastore_path, watch_uuid, "elements-error.json") | ||||
|         else: | ||||
|             target_path = os.path.join(self.datastore_path, watch_uuid, "elements.json") | ||||
|  | ||||
|         with open(target_path, 'w') as f: | ||||
|             f.write(json.dumps(data)) | ||||
|             f.close() | ||||
|  | ||||
| @@ -521,8 +528,39 @@ class ChangeDetectionStore: | ||||
|  | ||||
|     # We incorrectly stored last_changed when there was not a change, and then confused the output list table | ||||
|     def update_3(self): | ||||
|         # see https://github.com/dgtlmoon/changedetection.io/pull/835 | ||||
|         return | ||||
|  | ||||
|     # `last_changed` not needed, we pull that information from the history.txt index | ||||
|     def update_4(self): | ||||
|         for uuid, watch in self.data['watching'].items(): | ||||
|             # Be sure it's recalculated | ||||
|             p = watch.history | ||||
|             if watch.history_n < 2: | ||||
|                 watch['last_changed'] = 0 | ||||
|             try: | ||||
|                 # Remove it from the struct | ||||
|                 del(watch['last_changed']) | ||||
|             except: | ||||
|                 continue | ||||
|         return | ||||
|  | ||||
|  | ||||
|     def update_5(self): | ||||
|  | ||||
|         from changedetectionio.notification import ( | ||||
|             default_notification_body, | ||||
|             default_notification_format, | ||||
|             default_notification_title, | ||||
|         ) | ||||
|  | ||||
|         for uuid, watch in self.data['watching'].items(): | ||||
|             try: | ||||
|                 # If it's all the same to the system settings, then prefer system notification settings | ||||
|                 # include \r\n -> \n incase they already hit submit and the browser put \r in | ||||
|                 if watch.get('notification_body').replace('\r\n', '\n') == default_notification_body.replace('\r\n', '\n') and \ | ||||
|                         watch.get('notification_format') == default_notification_format and \ | ||||
|                         watch.get('notification_title').replace('\r\n', '\n') == default_notification_title.replace('\r\n', '\n') and \ | ||||
|                         watch.get('notification_urls') == self.__data['settings']['application']['notification_urls']: | ||||
|                         watch['notification_use_default'] = True | ||||
|                 else: | ||||
|                     watch['notification_use_default'] = False | ||||
|             except: | ||||
|                 continue | ||||
|         return | ||||
							
								
								
									
										7
									
								
								changedetectionio/templates/_pagination.jinja
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								changedetectionio/templates/_pagination.jinja
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| {% macro pagination(sorted_watches, total_per_page, current_page) %} | ||||
|   {{ sorted_watches|length }} | ||||
|  | ||||
|   {% for row in sorted_watches|batch(total_per_page, ' ') %} | ||||
|     {{ loop.index}} | ||||
|   {% endfor %} | ||||
| {% endmacro %} | ||||
| @@ -3,6 +3,9 @@ | ||||
| {% block content %} | ||||
| <script> | ||||
|     const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}"; | ||||
|     {% if last_error_screenshot %} | ||||
|     const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||
|     {% endif %} | ||||
| </script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> | ||||
|  | ||||
| @@ -43,15 +46,31 @@ | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <div class="tabs"> | ||||
|     <ul> | ||||
|         <li class="tab" id="default-tab"><a href="#text">Text</a></li> | ||||
|         {% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %} | ||||
|         {% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">Error Screenshot</a></li> {% endif %} | ||||
|         <li class="tab" id=""><a href="#text">Text</a></li> | ||||
|         <li class="tab" id="screenshot-tab"><a href="#screenshot">Screenshot</a></li> | ||||
|     </ul> | ||||
| </div> | ||||
|  | ||||
| <div id="diff-ui"> | ||||
|     <div class="tab-pane-inner" id="error-text"> | ||||
|         <div class="snapshot-age error">{{watch_a.error_text_ctime|format_seconds_ago}} seconds ago</div> | ||||
|         <pre> | ||||
|             {{ last_error_text }} | ||||
|         </pre> | ||||
|     </div> | ||||
|  | ||||
|     <div class="tab-pane-inner" id="error-screenshot"> | ||||
|         <div class="snapshot-age error">{{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div> | ||||
|         <img id="error-screenshot-img"  style="max-width: 80%" alt="Current error-ing screenshot from most recent request"/> | ||||
|     </div> | ||||
|  | ||||
|      <div class="tab-pane-inner" id="text"> | ||||
|          <div class="tip">Pro-tip: Use <strong>show current snapshot</strong> tab to visualise what will be ignored. | ||||
|          </div> | ||||
|          <div class="snapshot-age">{{watch_a.snapshot_text_ctime|format_timestamp_timeago}}</div> | ||||
|  | ||||
|          <table> | ||||
|              <tbody> | ||||
|              <tr> | ||||
| @@ -70,10 +89,10 @@ | ||||
|          <div class="tip"> | ||||
|              For now, Differences are performed on text, not graphically, only the latest screenshot is available. | ||||
|          </div> | ||||
|          </br> | ||||
|          {% if is_html_webdriver %} | ||||
|            {% if screenshot %} | ||||
|              <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/> | ||||
|             <div class="snapshot-age">{{watch_a.snapshot_screenshot_ctime|format_timestamp_timeago}}</div> | ||||
|             <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/> | ||||
|            {% else %} | ||||
|               No screenshot available just yet! Try rechecking the page. | ||||
|            {% endif %} | ||||
| @@ -88,7 +107,6 @@ | ||||
|  | ||||
| <script defer=""> | ||||
|  | ||||
|  | ||||
| var a = document.getElementById('a'); | ||||
| var b = document.getElementById('b'); | ||||
| var result = document.getElementById('result'); | ||||
|   | ||||
| @@ -23,7 +23,7 @@ | ||||
|  | ||||
|     <div class="tabs collapsable"> | ||||
|         <ul> | ||||
|             <li class="tab" id="default-tab"><a href="#general">General</a></li> | ||||
|             <li class="tab" id=""><a href="#general">General</a></li> | ||||
|             <li class="tab"><a href="#request">Request</a></li> | ||||
|             <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> | ||||
| @@ -87,6 +87,9 @@ | ||||
|                         </span> | ||||
|                     </div> | ||||
|                 {% endif %} | ||||
|                     <div  class="pure-control-group inline-radio"> | ||||
|                         {{ render_checkbox_field(form.ignore_status_codes) }} | ||||
|                     </div> | ||||
|                 <fieldset id="webdriver-override-options"> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.webdriver_delay) }} | ||||
| @@ -128,17 +131,15 @@ User-Agent: wonderbra 1.0") }} | ||||
|    \"car\":null | ||||
| }") }} | ||||
|                     </div> | ||||
|                     <div id="ignore-status-codes-option"> | ||||
|                         {{ render_checkbox_field(form.ignore_status_codes) }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <br/> | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="notifications"> | ||||
|                 <strong>Note: <i>These settings override the global settings for this watch.</i></strong> | ||||
|                 <fieldset> | ||||
|                     <div class="field-group"> | ||||
|                     <div  class="pure-control-group inline-radio"> | ||||
|                       {{ render_checkbox_field(form.notification_use_default) }} | ||||
|                     </div> | ||||
|                     <div class="field-group" id="notification-field-group"> | ||||
|                         {{ render_common_settings_form(form, current_base_url, emailprefix) }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
| @@ -307,9 +308,7 @@ Unavailable") }} | ||||
|  | ||||
|             <div id="actions"> | ||||
|                 <div class="pure-control-group"> | ||||
|  | ||||
|                       {{ render_button(form.save_button) }} {{ render_button(form.save_and_preview_button) }} | ||||
|  | ||||
|                     {{ render_button(form.save_button) }} | ||||
|                     <a href="{{url_for('form_delete', uuid=uuid)}}" | ||||
|                        class="pure-button button-small button-error ">Delete</a> | ||||
|                     <a href="{{url_for('clear_watch_history', uuid=uuid)}}" | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
|  | ||||
|     <div class="tabs collapsable"> | ||||
|         <ul> | ||||
|             <li class="tab" id="default-tab"><a href="#url-list">URL List</a></li> | ||||
|             <li class="tab" id=""><a href="#url-list">URL List</a></li> | ||||
|             <li class="tab"><a href="#distill-io">Distill.io</a></li> | ||||
|         </ul> | ||||
|     </div> | ||||
|   | ||||
| @@ -3,23 +3,39 @@ | ||||
| {% block content %} | ||||
| <script> | ||||
|     const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}"; | ||||
|     {% if last_error_screenshot %} | ||||
|     const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||
|     {% endif %} | ||||
| </script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> | ||||
|  | ||||
| <div id="settings"> | ||||
|     <h1>Current - {{watch.last_checked|format_timestamp_timeago}}</h1> | ||||
| </div> | ||||
|  | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <div class="tabs"> | ||||
|     <ul> | ||||
|         <li class="tab" id="default-tab"><a href="#text">Text</a></li> | ||||
|         {% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %} | ||||
|         {% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">Error Screenshot</a></li> {% endif %} | ||||
|         {% if history_n > 0 %} | ||||
|         <li class="tab" id="text-tab"><a href="#text">Text</a></li> | ||||
|         <li class="tab" id="screenshot-tab"><a href="#screenshot">Screenshot</a></li> | ||||
|         {% endif %} | ||||
|     </ul> | ||||
| </div> | ||||
|  | ||||
| <div id="diff-ui"> | ||||
|     <div class="tab-pane-inner" id="error-text"> | ||||
|         <div class="snapshot-age error">{{watch.error_text_ctime|format_seconds_ago}} seconds ago</div> | ||||
|         <pre> | ||||
|             {{ last_error_text }} | ||||
|         </pre> | ||||
|     </div> | ||||
|  | ||||
|     <div class="tab-pane-inner" id="error-screenshot"> | ||||
|         <div class="snapshot-age error">{{watch.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div> | ||||
|         <img id="error-screenshot-img"  style="max-width: 80%" alt="Current erroring screenshot from most recent request"/> | ||||
|     </div> | ||||
|  | ||||
|     <div class="tab-pane-inner" id="text"> | ||||
|         <div class="snapshot-age">{{watch.snapshot_text_ctime|format_timestamp_timeago}}</div> | ||||
|         <span class="ignored">Grey lines are ignored</span> <span class="triggered">Blue lines are triggers</span> | ||||
|         <table> | ||||
|             <tbody> | ||||
| @@ -33,6 +49,7 @@ | ||||
|             </tbody> | ||||
|         </table> | ||||
|     </div> | ||||
|  | ||||
|      <div class="tab-pane-inner" id="screenshot"> | ||||
|          <div class="tip"> | ||||
|              For now, Differences are performed on text, not graphically, only the latest screenshot is available. | ||||
| @@ -40,6 +57,7 @@ | ||||
|          </br> | ||||
|          {% if is_html_webdriver %} | ||||
|            {% if screenshot %} | ||||
|              <div class="snapshot-age">{{watch.snapshot_screenshot_ctime|format_timestamp_timeago}}</div> | ||||
|              <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/> | ||||
|            {% else %} | ||||
|               No screenshot available just yet! Try rechecking the page. | ||||
|   | ||||
| @@ -16,7 +16,7 @@ | ||||
| <div class="edit-form"> | ||||
|     <div class="tabs collapsable"> | ||||
|         <ul> | ||||
|             <li class="tab" id="default-tab"><a href="#general">General</a></li> | ||||
|             <li class="tab" id=""><a href="#general">General</a></li> | ||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||
|             <li class="tab"><a href="#fetching">Fetching</a></li> | ||||
|             <li class="tab"><a href="#filters">Global Filters</a></li> | ||||
| @@ -69,12 +69,6 @@ | ||||
|                         {{ render_checkbox_field(form.application.form.extract_title_as_title) }} | ||||
|                         <span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.real_browser_save_screenshot) }} | ||||
|                         <span class="pure-form-message-inline">When using a Chrome browser, a screenshot from the last check will be available on the Diff page</span> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }} | ||||
|                         <span class="pure-form-message-inline">When a page contains HTML, but no renderable text appears (empty page), is this considered a change?</span> | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_simple_field, render_field %} | ||||
| {% from '_pagination.jinja' import pagination %} | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script> | ||||
|  | ||||
| @@ -23,6 +24,14 @@ | ||||
|         </fieldset> | ||||
|         <span style="color:#eee; font-size: 80%;"><img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" /> Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></a></span> | ||||
|     </form> | ||||
|  | ||||
|     <form class="pure-form" action="{{ url_for('form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|     <div id="checkbox-operations"> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="pause">Pause</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="unpause">UnPause</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="background: #dd4242; font-size: 70%" name="op" value="delete">Delete</button> | ||||
|     </div> | ||||
|     <div> | ||||
|         <a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag }}">All</a> | ||||
|         {% for tag in tags %} | ||||
| @@ -32,22 +41,32 @@ | ||||
|         {% endfor %} | ||||
|     </div> | ||||
|  | ||||
|     {% set sort_order = request.args.get('order', 'asc') == 'asc' %} | ||||
|     {% set sort_attribute = request.args.get('sort', 'last_changed')   %} | ||||
|     {% set pagination_page = request.args.get('page', 0) %} | ||||
|  | ||||
|     <div id="watch-table-wrapper"> | ||||
|         <table class="pure-table pure-table-striped watch-table"> | ||||
|             <thead> | ||||
|             <tr> | ||||
|                 <th>#</th> | ||||
|                 <th><input style="vertical-align: middle" type="checkbox" id="check-all"/> #</th> | ||||
|                 <th></th> | ||||
|                 <th></th> | ||||
|                 <th>Last Checked</th> | ||||
|                 <th>Last Changed</th> | ||||
|                 {% set link_order = "desc" if sort_order else "asc" %} | ||||
|                 {% set arrow_span = "" %} | ||||
|                 <th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('index', sort='label', order=link_order)}}">Website <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)}}">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)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th></th> | ||||
|             </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|  | ||||
|             {% set sorted_watches = watches|sort(attribute=sort_attribute, reverse=sort_order) %} | ||||
|             {% for watch in sorted_watches %} | ||||
|  | ||||
|             {% for watch in watches|sort(attribute='last_changed', reverse=True) %} | ||||
|             {# WIP for pagination, disabled for now | ||||
|               {% if not ( loop.index >= 3 and loop.index <=4) %}{% continue %}{% endif %} --> | ||||
|              #} | ||||
|             <tr id="{{ watch.uuid }}" | ||||
|                 class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} | ||||
|                 {% if watch.last_error is defined and watch.last_error != False %}error{% endif %} | ||||
| @@ -55,9 +74,11 @@ | ||||
|                 {% if watch.paused is defined and watch.paused != False %}paused{% endif %} | ||||
|                 {% if watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}unviewed{% endif %} | ||||
|                 {% if watch.uuid in queued_uuids %}queued{% endif %}"> | ||||
|                 <td class="inline">{{ loop.index }}</td> | ||||
|                 <td class="inline paused-state state-{{watch.paused}}"><a href="{{url_for('index', pause=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause" title="Pause"/></a></td> | ||||
|  | ||||
|                 <td class="inline checkbox-uuid" ><input name="uuids"  type="checkbox" value="{{ watch.uuid}} "/> <span>{{ loop.index }}</span></td> | ||||
|                 <td class="inline watch-controls"> | ||||
|                     <a class="state-{{'on' if watch.paused }}" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks"/></a> | ||||
|                     <a class="state-{{'on' if watch.notification_muted}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications"/></a> | ||||
|                 </td> | ||||
|                 <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} | ||||
|                     <a class="external" target="_blank" rel="noopener" href="{{ watch.url.replace('source:','') }}"></a> | ||||
|                     <a href="{{url_for('form_share_put_watch', uuid=watch.uuid)}}"><img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread.svg')}}" /></a> | ||||
| @@ -88,7 +109,7 @@ | ||||
|                     {% if watch.history_n >= 2 %} | ||||
|                     <a href="{{ url_for('diff_history_page', uuid=watch.uuid) }}" target="{{watch.uuid}}" class="pure-button button-small pure-button-primary diff-link">Diff</a> | ||||
|                     {% else %} | ||||
|                         {% if watch.history_n == 1 %} | ||||
|                         {% if watch.history_n == 1 or (watch.history_n ==0 and watch.error_text_ctime )%} | ||||
|                             <a href="{{ url_for('preview_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button button-small pure-button-primary">Preview</a> | ||||
|                         {% endif %} | ||||
|                     {% endif %} | ||||
| @@ -111,6 +132,11 @@ | ||||
|                 <a href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a> | ||||
|             </li> | ||||
|         </ul> | ||||
|         {# WIP for pagination, disabled for now | ||||
|          {{ pagination(sorted_watches,3, pagination_page) }} | ||||
|          #} | ||||
|  | ||||
|     </div> | ||||
|     </form> | ||||
| </div> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -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 | ||||
| import logging | ||||
|  | ||||
|  | ||||
| @@ -29,14 +29,8 @@ def test_fetch_webdriver_content(client, live_server): | ||||
|  | ||||
|     assert b"1 Imported" in res.data | ||||
|     time.sleep(3) | ||||
|     attempt = 0 | ||||
|     while attempt < 20: | ||||
|         res = client.get(url_for("index")) | ||||
|         if not b'Checking now' in res.data: | ||||
|             break | ||||
|         logging.getLogger().info("Waiting for check to not say 'Checking now'..") | ||||
|         time.sleep(3) | ||||
|         attempt += 1 | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|  | ||||
|     res = client.get( | ||||
|   | ||||
| @@ -19,7 +19,6 @@ def test_check_access_control(app, client): | ||||
|         ) | ||||
|  | ||||
|         assert b"Password protection enabled." in res.data | ||||
|         assert b"LOG OUT" not in res.data | ||||
|  | ||||
|         # Check we hit the login | ||||
|         res = c.get(url_for("index"), follow_redirects=True) | ||||
| @@ -38,7 +37,40 @@ def test_check_access_control(app, client): | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|  | ||||
|         # Yes we are correctly logged in | ||||
|         assert b"LOG OUT" in res.data | ||||
|  | ||||
|         # 598 - Password should be set and not accidently removed | ||||
|         res = c.post( | ||||
|             url_for("settings_page"), | ||||
|             data={ | ||||
|                   "requests-time_between_check-minutes": 180, | ||||
|                   'application-fetch_backend': "html_requests"}, | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|  | ||||
|         res = c.get(url_for("logout"), | ||||
|             follow_redirects=True) | ||||
|  | ||||
|         res = c.get(url_for("settings_page"), | ||||
|             follow_redirects=True) | ||||
|  | ||||
|  | ||||
|         assert b"Login" in res.data | ||||
|  | ||||
|         res = c.get(url_for("login")) | ||||
|         assert b"Login" in res.data | ||||
|  | ||||
|  | ||||
|         res = c.post( | ||||
|             url_for("login"), | ||||
|             data={"password": "foobar"}, | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|  | ||||
|         # Yes we are correctly logged in | ||||
|         assert b"LOG OUT" in res.data | ||||
|  | ||||
|         res = c.get(url_for("settings_page")) | ||||
|  | ||||
|         # Menu should be available now | ||||
|   | ||||
| @@ -90,6 +90,14 @@ def test_check_basic_change_detection_functionality(client, live_server): | ||||
|     res = client.get(url_for("diff_history_page", uuid="first")) | ||||
|     assert b'Compare newest' in res.data | ||||
|  | ||||
|     # Check the [preview] pulls the right one | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b'which has this one new line' in res.data | ||||
|     assert b'Which is across multiple lines' not in res.data | ||||
|  | ||||
|     time.sleep(2) | ||||
|  | ||||
|     # Do this a few times.. ensures we dont accidently set the status | ||||
|   | ||||
| @@ -11,16 +11,17 @@ def test_setup(live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|  | ||||
| def test_error_handler(client, live_server): | ||||
| def _runner_test_http_errors(client, live_server, http_code, expected_text): | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write("Now you going to get a {} error code\n".format(http_code)) | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', | ||||
|                        status_code=403, | ||||
|                        status_code=http_code, | ||||
|                        _external=True) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
| @@ -28,20 +29,39 @@ def test_error_handler(client, live_server): | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(3) | ||||
|  | ||||
|     time.sleep(2) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     # no change | ||||
|     assert b'unviewed' not in res.data | ||||
|     assert b'Status Code 403' in res.data | ||||
|     assert bytes("just now".encode('utf-8')) in res.data | ||||
|     assert bytes(expected_text.encode('utf-8')) in res.data | ||||
|  | ||||
|  | ||||
|     # Error viewing tabs should appear | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b'Error Text' in res.data | ||||
|  | ||||
|     # 'Error Screenshot' only when in playwright mode | ||||
|     #assert b'Error Screenshot' in res.data | ||||
|  | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| def test_http_error_handler(client, live_server): | ||||
|     _runner_test_http_errors(client, live_server, 403, 'Access denied') | ||||
|     _runner_test_http_errors(client, live_server, 404, 'Page not found') | ||||
|     _runner_test_http_errors(client, live_server, 500, '(Internal server Error) received') | ||||
|     _runner_test_http_errors(client, live_server, 400, 'Error - Request returned a HTTP error code 400') | ||||
|  | ||||
| # Just to be sure error text is properly handled | ||||
| def test_error_text_handler(client, live_server): | ||||
| def test_DNS_errors(client, live_server): | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
| @@ -53,13 +73,11 @@ def test_error_text_handler(client, live_server): | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(3) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'Name or service not known' in res.data | ||||
|     # Should always record that we tried | ||||
|     assert bytes("just now".encode('utf-8')) in res.data | ||||
|  | ||||
|   | ||||
							
								
								
									
										134
									
								
								changedetectionio/tests/test_filter_exist_changes.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								changedetectionio/tests/test_filter_exist_changes.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,134 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| # https://www.reddit.com/r/selfhosted/comments/wa89kp/comment/ii3a4g7/?context=3 | ||||
| import os | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import set_original_response, live_server_setup | ||||
| from changedetectionio.model import App | ||||
|  | ||||
|  | ||||
| def set_response_without_filter(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <div id="nope-doesnt-exist">Some text thats the same</div>      | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def set_response_with_filter(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <div class="ticket-available">Ticket now on sale!</div>      | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
| def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_server): | ||||
| #  Filter knowingly doesn't exist, like someone setting up a known filter to see if some cinema tickets are on sale again | ||||
| #  And the page has that filter available | ||||
| #  Then I should get a notification | ||||
|  | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|     set_response_without_filter() | ||||
|  | ||||
|     # 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, "tag": 'cinema'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Watch added" in res.data | ||||
|  | ||||
|     # Give the thread time to pick up the first version | ||||
|     time.sleep(3) | ||||
|  | ||||
|     # 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') | ||||
|  | ||||
|     print(">>>> Notification URL: " + notification_url) | ||||
|  | ||||
|     # 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" | ||||
|                                                    ":-)", | ||||
|                               "notification_format": "Text"} | ||||
|  | ||||
|     notification_form_data.update({ | ||||
|         "url": test_url, | ||||
|         "tag": "my tag", | ||||
|         "title": "my title", | ||||
|         "headers": "", | ||||
|         "css_filter": '.ticket-available', | ||||
|         "fetch_backend": "html_requests"}) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data=notification_form_data, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     time.sleep(3) | ||||
|  | ||||
|     # 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) | ||||
|  | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
|  | ||||
|     with open("test-datastore/notification.txt", 'r') as f: | ||||
|         notification = f.read() | ||||
|  | ||||
|     assert 'Ticket now on sale' in notification | ||||
|     os.unlink("test-datastore/notification.txt") | ||||
|  | ||||
|  | ||||
|     # Test that if it gets removed, then re-added, we get a notification | ||||
|     # Remove the target and re-add it, we should get a new notification | ||||
|     set_response_without_filter() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(3) | ||||
|     assert not os.path.isfile("test-datastore/notification.txt") | ||||
|  | ||||
|     set_response_with_filter() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(3) | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
|  | ||||
| # Also test that the filter was updated after the first one was requested | ||||
| @@ -26,6 +26,13 @@ def run_filter_test(client, content_filter): | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|     # cleanup for the next | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     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) | ||||
| @@ -34,6 +41,7 @@ def run_filter_test(client, content_filter): | ||||
|         data={"url": test_url, "tag": ''}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"Watch added" in res.data | ||||
|  | ||||
|     # Give the thread time to pick up the first version | ||||
| @@ -67,6 +75,7 @@ def run_filter_test(client, content_filter): | ||||
|         "tag": "my tag", | ||||
|         "title": "my title", | ||||
|         "headers": "", | ||||
|         "filter_failure_notification_send": 'y', | ||||
|         "css_filter": content_filter, | ||||
|         "fetch_backend": "html_requests"}) | ||||
|  | ||||
| @@ -86,7 +95,7 @@ def run_filter_test(client, content_filter): | ||||
|         time.sleep(3) | ||||
|  | ||||
|     # We should see something in the frontend | ||||
|     assert b'Did the page change its layout' in res.data | ||||
|     assert b'Warning, filter' in res.data | ||||
|  | ||||
|     # Now it should exist and contain our "filter not found" alert | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
| @@ -132,3 +141,4 @@ def test_check_xpath_filter_failure_notification(client, live_server): | ||||
|     time.sleep(1) | ||||
|     run_filter_test(client, '//*[@id="nope-doesnt-exist"]') | ||||
|  | ||||
| # Test that notification is never sent | ||||
| @@ -137,54 +137,3 @@ def test_403_page_check_works_with_ignore_status_code(client, live_server): | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|  | ||||
| # Tests the whole stack works with staus codes ignored | ||||
| def test_403_page_check_fails_without_ignore_status_code(client, live_server): | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|     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', status_code=403, _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     # 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) | ||||
|  | ||||
|     # Goto the edit page, check our ignore option | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"url": test_url, "tag": "", "headers": "", 'fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     # 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) | ||||
|     #  Make a change | ||||
|     set_some_changed_response() | ||||
|  | ||||
|     # 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) | ||||
|  | ||||
|     # It should have 'unviewed' still | ||||
|     # Because it should be looking at only that 'sametext' id | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'Status Code 403' in res.data | ||||
|   | ||||
| @@ -71,6 +71,7 @@ def test_check_notification(client, live_server): | ||||
|         "url": test_url, | ||||
|         "tag": "my tag", | ||||
|         "title": "my title", | ||||
|         # No 'notification_use_default' here, so it's effectively False/off | ||||
|         "headers": "", | ||||
|         "fetch_backend": "html_requests"}) | ||||
|  | ||||
| @@ -215,3 +216,82 @@ def test_notification_validation(client, live_server): | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| # Check that the default VS watch specific notification is hit | ||||
| def test_check_notification_use_default(client, live_server): | ||||
|     set_original_response() | ||||
|     notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": test_url, "tag": ''}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Watch added" in res.data | ||||
|  | ||||
|     ## Setup the local one and enable it | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"notification_urls": notification_url, | ||||
|               "notification_title": "watch-notification", | ||||
|               "notification_body": "watch-body", | ||||
|               'notification_use_default': "True", | ||||
|               "notification_format": "Text", | ||||
|               "url": test_url, | ||||
|               "tag": "my tag", | ||||
|               "title": "my title", | ||||
|               "headers": "", | ||||
|               "fetch_backend": "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={"application-notification_title": "global-notifications-title", | ||||
|               "application-notification_body": "global-notifications-body\n", | ||||
|               "application-notification_format": "Text", | ||||
|               "application-notification_urls": notification_url, | ||||
|               "requests-time_between_check-minutes": 180, | ||||
|               "fetch_backend": "html_requests" | ||||
|               }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     # A change should by default trigger a notification of the global-notifications | ||||
|     time.sleep(1) | ||||
|     set_modified_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(2) | ||||
|     with open("test-datastore/notification.txt", "r") as f: | ||||
|         assert 'global-notifications-title' in f.read() | ||||
|  | ||||
|     ## Setup the local one and enable it | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"notification_urls": notification_url, | ||||
|               "notification_title": "watch-notification", | ||||
|               "notification_body": "watch-body", | ||||
|               # No 'notification_use_default' here, so it's effectively False/off = "dont use default, use this one" | ||||
|               "notification_format": "Text", | ||||
|               "url": test_url, | ||||
|               "tag": "my tag", | ||||
|               "title": "my title", | ||||
|               "headers": "", | ||||
|               "fetch_backend": "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     set_original_response() | ||||
|  | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(2) | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
|     with open("test-datastore/notification.txt", "r") as f: | ||||
|         assert 'watch-notification' in f.read() | ||||
|  | ||||
|  | ||||
|     # cleanup for the next | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -2,6 +2,8 @@ | ||||
|  | ||||
| from flask import make_response, request | ||||
| from flask import url_for | ||||
| import logging | ||||
| import time | ||||
|  | ||||
| def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
| @@ -68,6 +70,31 @@ def extract_api_key_from_UI(client): | ||||
|     api_key = m.group(1) | ||||
|     return api_key.strip() | ||||
|  | ||||
|  | ||||
| # kinda funky, but works for now | ||||
| def extract_UUID_from_client(client): | ||||
|     import re | ||||
|     res = client.get( | ||||
|         url_for("index"), | ||||
|     ) | ||||
|     # <span id="api-key">{{api_key}}</span> | ||||
|  | ||||
|     m = re.search('edit/(.+?)"', str(res.data)) | ||||
|     uuid = m.group(1) | ||||
|     return uuid.strip() | ||||
|  | ||||
| def wait_for_all_checks(client): | ||||
|     # Loop waiting until done.. | ||||
|     attempt=0 | ||||
|     while attempt < 60: | ||||
|         time.sleep(1) | ||||
|         res = client.get(url_for("index")) | ||||
|         if not b'Checking now' in res.data: | ||||
|             break | ||||
|         logging.getLogger().info("Waiting for watch-list to not say 'Checking now'.. {}".format(attempt)) | ||||
|  | ||||
|         attempt += 1 | ||||
|  | ||||
| def live_server_setup(live_server): | ||||
|  | ||||
|     @live_server.app.route('/test-endpoint') | ||||
| @@ -133,3 +160,4 @@ def live_server_setup(live_server): | ||||
|         return ret | ||||
|  | ||||
|     live_server.start() | ||||
|  | ||||
|   | ||||
							
								
								
									
										2
									
								
								changedetectionio/tests/visualselector/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								changedetectionio/tests/visualselector/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| """Tests for the app.""" | ||||
|  | ||||
							
								
								
									
										3
									
								
								changedetectionio/tests/visualselector/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changedetectionio/tests/visualselector/conftest.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| from .. import conftest | ||||
							
								
								
									
										35
									
								
								changedetectionio/tests/visualselector/test_fetch_data.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								changedetectionio/tests/visualselector/test_fetch_data.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
|  | ||||
| # Add a site in paused mode, add an invalid filter, we should still have visual selector data ready | ||||
| def test_visual_selector_content_ready(client, live_server): | ||||
|     import os | ||||
|  | ||||
|     assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||
|     live_server_setup(live_server) | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page, maybe better to use something we control? | ||||
|     # We use an external URL because the docker container is too difficult to setup to connect back to the pytest socket | ||||
|     test_url = 'https://news.ycombinator.com' | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": test_url, "tag": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first", unpause_on_save=1), | ||||
|         data={"css_filter": ".does-not-exist", "url": test_url, "tag": "", "headers": "", 'fetch_backend': "html_webdriver"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"unpaused" in res.data | ||||
|     time.sleep(1) | ||||
|     wait_for_all_checks(client) | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     assert os.path.isfile(os.path.join('test-datastore', uuid, 'last-screenshot.png')), "last-screenshot.png should exist" | ||||
|     assert os.path.isfile(os.path.join('test-datastore', uuid, 'elements.json')), "xpath elements.json data should exist" | ||||
| @@ -1,3 +1,4 @@ | ||||
| import os | ||||
| import threading | ||||
| import queue | ||||
| import time | ||||
| @@ -21,10 +22,70 @@ class update_worker(threading.Thread): | ||||
|         self.datastore = datastore | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     def send_filter_failure_notification(self, uuid): | ||||
|     def send_content_changed_notification(self, t, watch_uuid): | ||||
|  | ||||
|         from changedetectionio import diff | ||||
|  | ||||
|         n_object = {} | ||||
|         watch = self.datastore.data['watching'].get(watch_uuid, False) | ||||
|         if not watch: | ||||
|             return | ||||
|  | ||||
|         watch_history = watch.history | ||||
|         dates = list(watch_history.keys()) | ||||
|         # Theoretically it's possible that this could be just 1 long, | ||||
|         # - In the case that the timestamp key was not unique | ||||
|         if len(dates) == 1: | ||||
|             raise ValueError( | ||||
|                 "History index had 2 or more, but only 1 date loaded, timestamps were not unique? maybe two of the same timestamps got written, needs more delay?" | ||||
|             ) | ||||
|  | ||||
|         # Did it have any notification alerts to hit? | ||||
|         if not watch.get('notification_use_default') and len(watch['notification_urls']): | ||||
|             print(">>> Notifications queued for UUID from watch {}".format(watch_uuid)) | ||||
|             n_object['notification_urls'] = watch['notification_urls'] | ||||
|             n_object['notification_title'] = watch['notification_title'] | ||||
|             n_object['notification_body'] = watch['notification_body'] | ||||
|             n_object['notification_format'] = watch['notification_format'] | ||||
|  | ||||
|         # No? maybe theres a global setting, queue them all | ||||
|         elif watch.get('notification_use_default') and len(self.datastore.data['settings']['application']['notification_urls']): | ||||
|             print(">>> Watch notification URLs were empty, using GLOBAL notifications for UUID: {}".format(watch_uuid)) | ||||
|             n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls'] | ||||
|             n_object['notification_title'] = self.datastore.data['settings']['application']['notification_title'] | ||||
|             n_object['notification_body'] = self.datastore.data['settings']['application']['notification_body'] | ||||
|             n_object['notification_format'] = self.datastore.data['settings']['application']['notification_format'] | ||||
|         else: | ||||
|             print(">>> NO notifications queued, watch and global notification URLs were empty.") | ||||
|  | ||||
|         # Only prepare to notify if the rules above matched | ||||
|         if 'notification_urls' in n_object: | ||||
|             # HTML needs linebreak, but MarkDown and Text can use a linefeed | ||||
|             if n_object['notification_format'] == 'HTML': | ||||
|                 line_feed_sep = "</br>" | ||||
|             else: | ||||
|                 line_feed_sep = "\n" | ||||
|  | ||||
|             snapshot_contents = '' | ||||
|             with open(watch_history[dates[-1]], 'rb') as f: | ||||
|                 snapshot_contents = f.read() | ||||
|  | ||||
|             n_object.update({ | ||||
|                 'watch_url': watch['url'], | ||||
|                 'uuid': watch_uuid, | ||||
|                 'current_snapshot': snapshot_contents.decode('utf-8'), | ||||
|                 'diff': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], line_feed_sep=line_feed_sep), | ||||
|                 'diff_full': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], True, line_feed_sep=line_feed_sep) | ||||
|             }) | ||||
|  | ||||
|             self.notification_q.put(n_object) | ||||
|  | ||||
|     def send_filter_failure_notification(self, watch_uuid): | ||||
|  | ||||
|         threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts') | ||||
|         watch = self.datastore.data['watching'].get(uuid, False) | ||||
|         watch = self.datastore.data['watching'].get(watch_uuid, False) | ||||
|         if not watch: | ||||
|             return | ||||
|  | ||||
|         n_object = {'notification_title': 'Changedetection.io - Alert - CSS/xPath filter was not present in the page', | ||||
|                     'notification_body': "Your configured CSS/xPath filter of '{}' for {{watch_url}} did not appear on the page after {} attempts, did the page change layout?\n\nLink: {{base_url}}/edit/{{watch_uuid}}\n\nThanks - Your omniscient changedetection.io installation :)\n".format( | ||||
| @@ -42,10 +103,18 @@ class update_worker(threading.Thread): | ||||
|         if 'notification_urls' in n_object: | ||||
|             n_object.update({ | ||||
|                 'watch_url': watch['url'], | ||||
|                 'uuid': uuid | ||||
|                 'uuid': watch_uuid | ||||
|             }) | ||||
|             self.notification_q.put(n_object) | ||||
|             print("Sent filter not found notification for {}".format(uuid)) | ||||
|             print("Sent filter not found notification for {}".format(watch_uuid)) | ||||
|  | ||||
|     def cleanup_error_artifacts(self, uuid): | ||||
|         # All went fine, remove error artifacts | ||||
|         cleanup_files = ["last-error-screenshot.png", "last-error.txt"] | ||||
|         for f in cleanup_files: | ||||
|             full_path = os.path.join(self.datastore.datastore_path, uuid, f) | ||||
|             if os.path.isfile(full_path): | ||||
|                 os.unlink(full_path) | ||||
|  | ||||
|     def run(self): | ||||
|         from changedetectionio import fetch_site_status | ||||
| @@ -55,7 +124,7 @@ class update_worker(threading.Thread): | ||||
|         while not self.app.config.exit.is_set(): | ||||
|  | ||||
|             try: | ||||
|                 uuid = self.q.get(block=False) | ||||
|                 priority, uuid = self.q.get(block=False) | ||||
|             except queue.Empty: | ||||
|                 pass | ||||
|  | ||||
| @@ -63,16 +132,17 @@ class update_worker(threading.Thread): | ||||
|                 self.current_uuid = uuid | ||||
|  | ||||
|                 if uuid in list(self.datastore.data['watching'].keys()): | ||||
|  | ||||
|                     changed_detected = False | ||||
|                     contents = "" | ||||
|                     contents = b'' | ||||
|                     screenshot = False | ||||
|                     update_obj= {} | ||||
|                     xpath_data = False | ||||
|                     process_changedetection_results = True | ||||
|                     print("> Processing UUID {} Priority {} URL {}".format(uuid, priority, self.datastore.data['watching'][uuid]['url'])) | ||||
|                     now = time.time() | ||||
|  | ||||
|                     try: | ||||
|                         changed_detected, update_obj, contents, screenshot, xpath_data = update_handler.run(uuid) | ||||
|                         changed_detected, update_obj, contents = update_handler.run(uuid) | ||||
|                         # 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 | ||||
| @@ -80,26 +150,61 @@ class update_worker(threading.Thread): | ||||
|                             raise Exception("Error - returned data from the fetch handler SHOULD be bytes") | ||||
|                     except PermissionError as e: | ||||
|                         self.app.logger.error("File permission error updating", uuid, str(e)) | ||||
|                         process_changedetection_results = False | ||||
|                     except content_fetcher.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 | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': "Got HTML content but no text found."}) | ||||
|                     except FilterNotFoundInResponse as e: | ||||
|                         err_text = "Filter '{}' not found - Did the page change its layout?".format(str(e)) | ||||
|                         c = 0 | ||||
|                         if self.datastore.data['watching'].get(uuid, False): | ||||
|                             c = self.datastore.data['watching'][uuid].get('consecutive_filter_failures', 5) | ||||
|                         c += 1 | ||||
|                         # Backend (not filters) gave zero output | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': "Got HTML content but no text found (With {} reply code).".format(e.status_code)}) | ||||
|                         if e.screenshot: | ||||
|                             self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot) | ||||
|                         process_changedetection_results = False | ||||
|  | ||||
|                         # Send notification if we reached the threshold? | ||||
|                         threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0) | ||||
|                         print("Filter for {} not found, consecutive_filter_failures: {}".format(uuid, c)) | ||||
|                         if threshold >0 and c >= threshold: | ||||
|                             self.send_filter_failure_notification(uuid) | ||||
|                             c = 0 | ||||
|                     except content_fetcher.Non200ErrorCodeReceived as e: | ||||
|                         if e.status_code == 403: | ||||
|                             err_text = "Error - 403 (Access denied) received" | ||||
|                         elif e.status_code == 404: | ||||
|                             err_text = "Error - 404 (Page not found) received" | ||||
|                         elif e.status_code == 500: | ||||
|                             err_text = "Error - 500 (Internal server Error) received" | ||||
|                         else: | ||||
|                             err_text = "Error - Request returned a HTTP error code {}".format(str(e.status_code)) | ||||
|  | ||||
|                         if e.screenshot: | ||||
|                             self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot, as_error=True) | ||||
|                         if e.xpath_data: | ||||
|                             self.datastore.save_xpath_data(watch_uuid=uuid, data=e.xpath_data, as_error=True) | ||||
|                         if e.page_text: | ||||
|                             self.datastore.save_error_text(watch_uuid=uuid, contents=e.page_text) | ||||
|  | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||
|                                                                            'consecutive_filter_failures': c}) | ||||
|                                                                            # So that we get a trigger when the content is added again | ||||
|                                                                            'previous_md5': ''}) | ||||
|                         process_changedetection_results = False | ||||
|  | ||||
|                     except FilterNotFoundInResponse as e: | ||||
|                         err_text = "Warning, filter '{}' not found".format(str(e)) | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||
|                                                                            # So that we get a trigger when the content is added again | ||||
|                                                                            'previous_md5': ''}) | ||||
|  | ||||
|                         # Only when enabled, send the notification | ||||
|                         if self.datastore.data['watching'][uuid].get('filter_failure_notification_send', False): | ||||
|                             c = self.datastore.data['watching'][uuid].get('consecutive_filter_failures', 5) | ||||
|                             c += 1 | ||||
|                             # Send notification if we reached the threshold? | ||||
|                             threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts', | ||||
|                                                                                            0) | ||||
|                             print("Filter for {} not found, consecutive_filter_failures: {}".format(uuid, c)) | ||||
|                             if threshold > 0 and c >= threshold: | ||||
|                                 if not self.datastore.data['watching'][uuid].get('notification_muted'): | ||||
|                                     self.send_filter_failure_notification(uuid) | ||||
|                                 c = 0 | ||||
|  | ||||
|                             self.datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c}) | ||||
|  | ||||
|                         process_changedetection_results = True | ||||
|  | ||||
|                     except content_fetcher.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) | ||||
| @@ -109,15 +214,41 @@ class update_worker(threading.Thread): | ||||
|                         err_text = "Screenshot unavailable, page did not render fully in the expected time - 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_fetcher.JSActionExceptions as e: | ||||
|                         err_text = "Error running JS Actions - Page request - "+e.message | ||||
|                         if e.screenshot: | ||||
|                             self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot, as_error=True) | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||
|                                                                            'last_check_status': e.status_code}) | ||||
|                     except content_fetcher.PageUnloadable as e: | ||||
|                         err_text = "Page request from server didnt respond correctly" | ||||
|                         if e.message: | ||||
|                             err_text = "{} - {}".format(err_text, e.message) | ||||
|  | ||||
|                         if e.screenshot: | ||||
|                             self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot, as_error=True) | ||||
|  | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||
|                                                                            'last_check_status': e.status_code}) | ||||
|                     except Exception as e: | ||||
|                         self.app.logger.error("Exception reached processing watch UUID: %s - %s", uuid, str(e)) | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)}) | ||||
|  | ||||
|                         # Other serious error | ||||
|                         process_changedetection_results = False | ||||
|                     else: | ||||
|                         # Crash protection, the watch entry could have been removed by this point (during a slow chrome fetch etc) | ||||
|                         if not self.datastore.data['watching'].get(uuid): | ||||
|                             continue | ||||
|  | ||||
|                         # Mark that we never had any failures | ||||
|                         if not self.datastore.data['watching'][uuid].get('ignore_status_codes'): | ||||
|                             update_obj['consecutive_filter_failures'] = 0 | ||||
|  | ||||
|                         self.cleanup_error_artifacts(uuid) | ||||
|  | ||||
|                     # Different exceptions mean that we may or may not want to bump the snapshot, trigger notifications etc | ||||
|                     if process_changedetection_results: | ||||
|                         try: | ||||
|                             watch = self.datastore.data['watching'][uuid] | ||||
|                             fname = "" # Saved history text filename | ||||
| @@ -125,68 +256,19 @@ class update_worker(threading.Thread): | ||||
|                             # For the FIRST time we check a site, or a change detected, save the snapshot. | ||||
|                             if changed_detected or not watch['last_checked']: | ||||
|                                 # A change was detected | ||||
|                                 fname = watch.save_history_text(contents=contents, timestamp=str(round(time.time()))) | ||||
|                                 watch.save_history_text(contents=contents, timestamp=str(round(time.time()))) | ||||
|  | ||||
|                             # Generally update anything interesting returned | ||||
|                             update_obj['consecutive_filter_failures'] = 0 | ||||
|                             self.datastore.update_watch(uuid=uuid, update_obj=update_obj) | ||||
|  | ||||
|                             # A change was detected | ||||
|                             if changed_detected: | ||||
|                                 n_object = {} | ||||
|                                 print (">> Change detected in UUID {} - {}".format(uuid, watch['url'])) | ||||
|  | ||||
|                                 # Notifications should only trigger on the second time (first time, we gather the initial snapshot) | ||||
|                                 if watch.history_n >= 2: | ||||
|                                     # Atleast 2, means there really was a change | ||||
|                                     self.datastore.update_watch(uuid=uuid, update_obj={'last_changed': round(now)}) | ||||
|                                     if not self.datastore.data['watching'][uuid].get('notification_muted'): | ||||
|                                         self.send_content_changed_notification(self, watch_uuid=uuid) | ||||
|  | ||||
|                                     watch_history = watch.history | ||||
|                                     dates = list(watch_history.keys()) | ||||
|                                     # Theoretically it's possible that this could be just 1 long, | ||||
|                                     # - In the case that the timestamp key was not unique | ||||
|                                     if len(dates) == 1: | ||||
|                                         raise ValueError( | ||||
|                                             "History index had 2 or more, but only 1 date loaded, timestamps were not unique? maybe two of the same timestamps got written, needs more delay?" | ||||
|                                         ) | ||||
|                                     prev_fname = watch_history[dates[-2]] | ||||
|  | ||||
|                                     # Did it have any notification alerts to hit? | ||||
|                                     if len(watch['notification_urls']): | ||||
|                                         print(">>> Notifications queued for UUID from watch {}".format(uuid)) | ||||
|                                         n_object['notification_urls'] = watch['notification_urls'] | ||||
|                                         n_object['notification_title'] = watch['notification_title'] | ||||
|                                         n_object['notification_body'] = watch['notification_body'] | ||||
|                                         n_object['notification_format'] = watch['notification_format'] | ||||
|  | ||||
|                                     # No? maybe theres a global setting, queue them all | ||||
|                                     elif len(self.datastore.data['settings']['application']['notification_urls']): | ||||
|                                         print(">>> Watch notification URLs were empty, using GLOBAL notifications for UUID: {}".format(uuid)) | ||||
|                                         n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls'] | ||||
|                                         n_object['notification_title'] = self.datastore.data['settings']['application']['notification_title'] | ||||
|                                         n_object['notification_body'] = self.datastore.data['settings']['application']['notification_body'] | ||||
|                                         n_object['notification_format'] = self.datastore.data['settings']['application']['notification_format'] | ||||
|                                     else: | ||||
|                                         print(">>> NO notifications queued, watch and global notification URLs were empty.") | ||||
|  | ||||
|                                     # Only prepare to notify if the rules above matched | ||||
|                                     if 'notification_urls' in n_object: | ||||
|                                         # HTML needs linebreak, but MarkDown and Text can use a linefeed | ||||
|                                         if n_object['notification_format'] == 'HTML': | ||||
|                                             line_feed_sep = "</br>" | ||||
|                                         else: | ||||
|                                             line_feed_sep = "\n" | ||||
|  | ||||
|                                         from changedetectionio import diff | ||||
|                                         n_object.update({ | ||||
|                                             'watch_url': watch['url'], | ||||
|                                             'uuid': uuid, | ||||
|                                             'current_snapshot': contents.decode('utf-8'), | ||||
|                                             'diff': diff.render_diff(prev_fname, fname, line_feed_sep=line_feed_sep), | ||||
|                                             'diff_full': diff.render_diff(prev_fname, fname, True, line_feed_sep=line_feed_sep) | ||||
|                                         }) | ||||
|  | ||||
|                                         self.notification_q.put(n_object) | ||||
|  | ||||
|                         except Exception as e: | ||||
|                             # Catch everything possible here, so that if a worker crashes, we don't lose it until restart! | ||||
| @@ -194,16 +276,16 @@ class update_worker(threading.Thread): | ||||
|                             self.app.logger.error("Exception reached processing watch UUID: %s - %s", uuid, str(e)) | ||||
|                             self.datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)}) | ||||
|  | ||||
|                     finally: | ||||
|                         # Always record that we atleast tried | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - now, 3), | ||||
|                                                                            'last_checked': round(time.time())}) | ||||
|  | ||||
|                         # Always save the screenshot if it's available | ||||
|                         if screenshot: | ||||
|                             self.datastore.save_screenshot(watch_uuid=uuid, screenshot=screenshot) | ||||
|                         if xpath_data: | ||||
|                             self.datastore.save_xpath_data(watch_uuid=uuid, data=xpath_data) | ||||
|                     # Always record that we atleast tried | ||||
|                     self.datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - now, 3), | ||||
|                                                                        'last_checked': round(time.time())}) | ||||
|  | ||||
|                     # Always save the screenshot if it's available | ||||
|                     if update_handler.screenshot: | ||||
|                         self.datastore.save_screenshot(watch_uuid=uuid, screenshot=update_handler.screenshot) | ||||
|                     if update_handler.xpath_data: | ||||
|                         self.datastore.save_xpath_data(watch_uuid=uuid, data=update_handler.xpath_data) | ||||
|  | ||||
|  | ||||
|                 self.current_uuid = None  # Done | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 209 KiB | 
| @@ -18,7 +18,7 @@ wtforms ~= 3.0 | ||||
| jsonpath-ng ~= 1.5.3 | ||||
|  | ||||
| # Notification library | ||||
| apprise ~= 0.9.9 | ||||
| apprise ~= 1.0.0 | ||||
|  | ||||
| # apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 | ||||
| paho-mqtt | ||||
|   | ||||
		Reference in New Issue
	
	Block a user