mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 06:37:41 +00:00 
			
		
		
		
	Compare commits
	
		
			32 Commits
		
	
	
		
			sigint-sig
			...
			0.45.4
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | dbb76f3618 | ||
|   | 4ae27af511 | ||
|   | e1860549dc | ||
|   | 9765d56a23 | ||
|   | 349111eb35 | ||
|   | 71e50569a0 | ||
|   | c372942295 | ||
|   | 0aef5483d9 | ||
|   | c266c64b94 | ||
|   | 32e5498a9d | ||
|   | 0ba7928d58 | ||
|   | 1709e8f936 | ||
|   | b16d65741c | ||
|   | 1cadcc6d15 | ||
|   | b58d521d19 | ||
|   | 52225f2ad8 | ||
|   | 7220afab0a | ||
|   | 1c0fe4c23e | ||
|   | 4f6b0eb8a5 | ||
|   | f707c914b6 | ||
|   | 9cb636e638 | ||
|   | 1d5fe51157 | ||
|   | c0b49d3be9 | ||
|   | c4dc85525f | ||
|   | 26159840c8 | ||
|   | 522e9786c6 | ||
|   | 9ce86a2835 | ||
|   | f9f6300a70 | ||
|   | 7734b22a19 | ||
|   | da421fe110 | ||
|   | 3e2b55a46f | ||
|   | 7ace259d70 | 
							
								
								
									
										4
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							| @@ -29,8 +29,8 @@ jobs: | ||||
|           docker network create changedet-network | ||||
|  | ||||
|           # Selenium+browserless | ||||
|           docker run --network changedet-network -d --hostname selenium  -p 4444:4444 --rm --shm-size="2g"  selenium/standalone-chrome-debug:3.141.59 | ||||
|           docker run --network changedet-network -d --hostname browserless -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm  -p 3000:3000  --shm-size="2g"  browserless/chrome:1.53-chrome-stable | ||||
|           docker run --network changedet-network -d --hostname selenium  -p 4444:4444 --rm --shm-size="2g"  selenium/standalone-chrome:4.14.1 | ||||
|           docker run --network changedet-network -d --hostname browserless -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm  -p 3000:3000  --shm-size="2g"  browserless/chrome:1.60-chrome-stable | ||||
|  | ||||
|       - name: Build changedetection.io container for testing | ||||
|         run: |          | ||||
|   | ||||
| @@ -20,6 +20,11 @@ WORKDIR /install | ||||
|  | ||||
| COPY requirements.txt /requirements.txt | ||||
|  | ||||
| # Instructing pip to fetch wheels from piwheels.org" on ARMv6 and ARMv7 machines | ||||
| RUN if [ "$(dpkg --print-architecture)" = "armhf" ] || [ "$(dpkg --print-architecture)" = "armel" ]; then \ | ||||
|       printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf; \ | ||||
|     fi; | ||||
|  | ||||
| RUN pip install --target=/dependencies -r /requirements.txt | ||||
|  | ||||
| # Playwright is an alternative to Selenium | ||||
|   | ||||
| @@ -38,7 +38,7 @@ from flask_paginate import Pagination, get_page_parameter | ||||
| from changedetectionio import html_tools | ||||
| from changedetectionio.api import api_v1 | ||||
|  | ||||
| __version__ = '0.45.3' | ||||
| __version__ = '0.45.4' | ||||
|  | ||||
| from changedetectionio.store import BASE_URL_NOT_SET_TEXT | ||||
|  | ||||
| @@ -416,11 +416,18 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         # Sort by last_changed and add the uuid which is usually the key.. | ||||
|         sorted_watches = [] | ||||
|         with_errors = request.args.get('with_errors') == "1" | ||||
|         errored_count = 0 | ||||
|         search_q = request.args.get('q').strip().lower() if request.args.get('q') else False | ||||
|         for uuid, watch in datastore.data['watching'].items(): | ||||
|             if with_errors and not watch.get('last_error'): | ||||
|                 continue | ||||
|  | ||||
|             if limit_tag and not limit_tag in watch['tags']: | ||||
|                     continue | ||||
|  | ||||
|             if watch.get('last_error'): | ||||
|                 errored_count += 1 | ||||
|                  | ||||
|             if search_q: | ||||
|                 if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower(): | ||||
|                     sorted_watches.append(watch) | ||||
| @@ -442,6 +449,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                                  active_tag=limit_tag, | ||||
|                                  app_rss_token=datastore.data['settings']['application']['rss_access_token'], | ||||
|                                  datastore=datastore, | ||||
|                                  errored_count=errored_count, | ||||
|                                  form=form, | ||||
|                                  guid=datastore.data['app_guid'], | ||||
|                                  has_proxies=datastore.proxy_list, | ||||
| @@ -622,7 +630,6 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|             if request.args.get('unpause_on_save'): | ||||
|                 extra_update_obj['paused'] = False | ||||
|  | ||||
|             # Re #110, if they submit the same as the default value, set it to None, so we continue to follow the default | ||||
|             # Assume we use the default value, unless something relevant is different, then use the form value | ||||
|             # values could be None, 0 etc. | ||||
| @@ -708,11 +715,11 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|             # Only works reliably with Playwright | ||||
|             visualselector_enabled = os.getenv('PLAYWRIGHT_DRIVER_URL', False) and is_html_webdriver | ||||
|  | ||||
|             output = render_template("edit.html", | ||||
|                                      available_processors=processors.available_processors(), | ||||
|                                      browser_steps_config=browser_step_ui_config, | ||||
|                                      emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||
|                                      extra_title=f" - Edit - {watch.label}", | ||||
|                                      form=form, | ||||
|                                      has_default_notification_urls=True if len(datastore.data['settings']['application']['notification_urls']) else False, | ||||
|                                      has_empty_checktime=using_default_check_time, | ||||
| @@ -856,7 +863,10 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|     def mark_all_viewed(): | ||||
|  | ||||
|         # Save the current newest history as the most recently viewed | ||||
|         with_errors = request.args.get('with_errors') == "1" | ||||
|         for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|             if with_errors and not watch.get('last_error'): | ||||
|                 continue | ||||
|             datastore.set_last_viewed(watch_uuid, int(time.time())) | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
| @@ -912,21 +922,29 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         # Read as binary and force decode as UTF-8 | ||||
|         # Windows may fail decode in python if we just use 'r' mode (chardet decode exception) | ||||
|         try: | ||||
|             newest_version_file_contents = watch.get_history_snapshot(dates[-1]) | ||||
|         except Exception as e: | ||||
|             newest_version_file_contents = "Unable to read {}.\n".format(dates[-1]) | ||||
|  | ||||
|         previous_version = request.args.get('previous_version') | ||||
|         previous_timestamp = dates[-2] | ||||
|         if previous_version: | ||||
|             previous_timestamp = previous_version | ||||
|         from_version = request.args.get('from_version') | ||||
|         from_version_index = -2 # second newest | ||||
|         if from_version and from_version in dates: | ||||
|             from_version_index = dates.index(from_version) | ||||
|         else: | ||||
|             from_version = dates[from_version_index] | ||||
|  | ||||
|         try: | ||||
|             previous_version_file_contents = watch.get_history_snapshot(previous_timestamp) | ||||
|             from_version_file_contents = watch.get_history_snapshot(dates[from_version_index]) | ||||
|         except Exception as e: | ||||
|             previous_version_file_contents = "Unable to read {}.\n".format(previous_timestamp) | ||||
|             from_version_file_contents = "Unable to read to-version at index{}.\n".format(dates[from_version_index]) | ||||
|  | ||||
|         to_version = request.args.get('to_version') | ||||
|         to_version_index = -1 | ||||
|         if to_version and to_version in dates: | ||||
|             to_version_index = dates.index(to_version) | ||||
|         else: | ||||
|             to_version = dates[to_version_index] | ||||
|  | ||||
|         try: | ||||
|             to_version_file_contents = watch.get_history_snapshot(dates[to_version_index]) | ||||
|         except Exception as e: | ||||
|             to_version_file_contents = "Unable to read to-version at index{}.\n".format(dates[to_version_index]) | ||||
|  | ||||
|         screenshot_url = watch.get_screenshot() | ||||
|  | ||||
| @@ -942,22 +960,24 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         output = render_template("diff.html", | ||||
|                                  current_diff_url=watch['url'], | ||||
|                                  current_previous_version=str(previous_version), | ||||
|                                  from_version=str(from_version), | ||||
|                                  to_version=str(to_version), | ||||
|                                  extra_stylesheets=extra_stylesheets, | ||||
|                                  extra_title=" - Diff - {}".format(watch['title'] if watch['title'] else watch['url']), | ||||
|                                  extra_title=f" - Diff - {watch.label}", | ||||
|                                  extract_form=extract_form, | ||||
|                                  is_html_webdriver=is_html_webdriver, | ||||
|                                  last_error=watch['last_error'], | ||||
|                                  last_error_screenshot=watch.get_error_snapshot(), | ||||
|                                  last_error_text=watch.get_error_text(), | ||||
|                                  left_sticky=True, | ||||
|                                  newest=newest_version_file_contents, | ||||
|                                  newest=to_version_file_contents, | ||||
|                                  newest_version_timestamp=dates[-1], | ||||
|                                  password_enabled_and_share_is_off=password_enabled_and_share_is_off, | ||||
|                                  previous=previous_version_file_contents, | ||||
|                                  from_version_file_contents=from_version_file_contents, | ||||
|                                  to_version_file_contents=to_version_file_contents, | ||||
|                                  screenshot=screenshot_url, | ||||
|                                  uuid=uuid, | ||||
|                                  versions=dates[:-1], # All except current/last | ||||
|                                  versions=dates, # All except current/last | ||||
|                                  watch_a=watch | ||||
|                                  ) | ||||
|  | ||||
| @@ -1255,6 +1275,8 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         # Forced recheck will skip the 'skip if content is the same' rule (, 'reprocess_existing_data': True}))) | ||||
|         tag = request.args.get('tag') | ||||
|         uuid = request.args.get('uuid') | ||||
|         with_errors = request.args.get('with_errors') == "1" | ||||
|  | ||||
|         i = 0 | ||||
|  | ||||
|         running_uuids = [] | ||||
| @@ -1270,6 +1292,8 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             # Items that have this current tag | ||||
|             for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|                 if tag in watch.get('tags', {}): | ||||
|                     if with_errors and not watch.get('last_error'): | ||||
|                         continue | ||||
|                     if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']: | ||||
|                         update_q.put( | ||||
|                             queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False}) | ||||
| @@ -1280,8 +1304,11 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             # No tag, no uuid, add everything. | ||||
|             for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|                 if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']: | ||||
|                     if with_errors and not watch.get('last_error'): | ||||
|                         continue | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False})) | ||||
|                     i += 1 | ||||
|  | ||||
|         flash("{} watches queued for rechecking.".format(i)) | ||||
|         return redirect(url_for('index', tag=tag)) | ||||
|  | ||||
|   | ||||
| @@ -23,8 +23,10 @@ | ||||
|  | ||||
| from distutils.util import strtobool | ||||
| from flask import Blueprint, request, make_response | ||||
| import os | ||||
| import logging | ||||
| import os | ||||
| import re | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio import login_optionally_required | ||||
|  | ||||
| @@ -44,7 +46,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|  | ||||
|  | ||||
|         # We keep the playwright session open for many minutes | ||||
|         seconds_keepalive = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60 | ||||
|         keepalive_seconds = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60 | ||||
|  | ||||
|         browsersteps_start_session = {'start_time': time.time()} | ||||
|  | ||||
| @@ -56,16 +58,18 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|             # Start the Playwright context, which is actually a nodejs sub-process and communicates over STDIN/STDOUT pipes | ||||
|             io_interface_context = io_interface_context.start() | ||||
|  | ||||
|         keepalive_ms = ((keepalive_seconds + 3) * 1000) | ||||
|         base_url = os.getenv('PLAYWRIGHT_DRIVER_URL', '') | ||||
|         a = "?" if not '?' in base_url else '&' | ||||
|         base_url += a + f"timeout={keepalive_ms}" | ||||
|  | ||||
|         # keep it alive for 10 seconds more than we advertise, sometimes it helps to keep it shutting down cleanly | ||||
|         keepalive = "&timeout={}".format(((seconds_keepalive + 3) * 1000)) | ||||
|         try: | ||||
|             browsersteps_start_session['browser'] = io_interface_context.chromium.connect_over_cdp( | ||||
|                 os.getenv('PLAYWRIGHT_DRIVER_URL', '') + keepalive) | ||||
|             browsersteps_start_session['browser'] = io_interface_context.chromium.connect_over_cdp(base_url) | ||||
|         except Exception as e: | ||||
|             if 'ECONNREFUSED' in str(e): | ||||
|                 return make_response('Unable to start the Playwright Browser session, is it running?', 401) | ||||
|             else: | ||||
|                 # Other errors, bad URL syntax, bad reply etc | ||||
|                 return make_response(str(e), 401) | ||||
|  | ||||
|         proxy_id = datastore.get_preferred_proxy_for_watch(uuid=watch_uuid) | ||||
| @@ -118,6 +122,31 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|         print("Starting connection with playwright - done") | ||||
|         return {'browsersteps_session_id': browsersteps_session_id} | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @browser_steps_blueprint.route("/browsersteps_image", methods=['GET']) | ||||
|     def browser_steps_fetch_screenshot_image(): | ||||
|         from flask import ( | ||||
|             make_response, | ||||
|             request, | ||||
|             send_from_directory, | ||||
|         ) | ||||
|         uuid = request.args.get('uuid') | ||||
|         step_n = int(request.args.get('step_n')) | ||||
|  | ||||
|         watch = datastore.data['watching'].get(uuid) | ||||
|         filename = f"step_before-{step_n}.jpeg" if request.args.get('type', '') == 'before' else f"step_{step_n}.jpeg" | ||||
|  | ||||
|         if step_n and watch and os.path.isfile(os.path.join(watch.watch_data_dir, filename)): | ||||
|             response = make_response(send_from_directory(directory=watch.watch_data_dir, path=filename)) | ||||
|             response.headers['Content-type'] = 'image/jpeg' | ||||
|             response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' | ||||
|             response.headers['Pragma'] = 'no-cache' | ||||
|             response.headers['Expires'] = 0 | ||||
|             return response | ||||
|  | ||||
|         else: | ||||
|             return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401) | ||||
|  | ||||
|     # A request for an action was received | ||||
|     @login_optionally_required | ||||
|     @browser_steps_blueprint.route("/browsersteps_update", methods=['POST']) | ||||
|   | ||||
| @@ -77,13 +77,13 @@ class steppable_browser_interface(): | ||||
|     def action_goto_url(self, selector=None, value=None): | ||||
|         # self.page.set_viewport_size({"width": 1280, "height": 5000}) | ||||
|         now = time.time() | ||||
|         response = self.page.goto(value, timeout=0, wait_until='commit') | ||||
|  | ||||
|         # Wait_until = commit | ||||
|         # - `'commit'` - consider operation to be finished when network response is received and the document started loading. | ||||
|         # Better to not use any smarts from Playwright and just wait an arbitrary number of seconds | ||||
|         # This seemed to solve nearly all 'TimeoutErrors' | ||||
|         response = self.page.goto(value, timeout=0, wait_until='load') | ||||
|         # Should be the same as the puppeteer_fetch.js methods, means, load with no timeout set (skip timeout) | ||||
|         #and also wait for seconds ? | ||||
|         #await page.waitForTimeout(1000); | ||||
|         #await page.waitForTimeout(extra_wait_ms); | ||||
|         print("Time to goto URL ", time.time() - now) | ||||
|         return response | ||||
|  | ||||
|     def action_click_element_containing_text(self, selector=None, value=''): | ||||
|         if not len(value.strip()): | ||||
| @@ -99,7 +99,8 @@ class steppable_browser_interface(): | ||||
|         self.page.fill(selector, value, timeout=10 * 1000) | ||||
|  | ||||
|     def action_execute_js(self, selector, value): | ||||
|         self.page.evaluate(value) | ||||
|         response = self.page.evaluate(value) | ||||
|         return response | ||||
|  | ||||
|     def action_click_element(self, selector, value): | ||||
|         print("Clicking element") | ||||
| @@ -138,13 +139,13 @@ class steppable_browser_interface(): | ||||
|     def action_wait_for_text(self, selector, value): | ||||
|         import json | ||||
|         v = json.dumps(value) | ||||
|         self.page.wait_for_function(f'document.querySelector("body").innerText.includes({v});', timeout=90000) | ||||
|         self.page.wait_for_function(f'document.querySelector("body").innerText.includes({v});', timeout=30000) | ||||
|  | ||||
|     def action_wait_for_text_in_element(self, selector, value): | ||||
|         import json | ||||
|         s = json.dumps(selector) | ||||
|         v = json.dumps(value) | ||||
|         self.page.wait_for_function(f'document.querySelector({s}).innerText.includes({v});', timeout=90000) | ||||
|         self.page.wait_for_function(f'document.querySelector({s}).innerText.includes({v});', timeout=30000) | ||||
|  | ||||
|     # @todo - in the future make some popout interface to capture what needs to be set | ||||
|     # https://playwright.dev/python/docs/api/class-keyboard | ||||
|   | ||||
| @@ -159,6 +159,16 @@ class Fetcher(): | ||||
|         """ | ||||
|         return {k.lower(): v for k, v in self.headers.items()} | ||||
|  | ||||
|     def browser_steps_get_valid_steps(self): | ||||
|         if self.browser_steps is not None and len(self.browser_steps): | ||||
|             valid_steps = filter( | ||||
|                 lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'), | ||||
|                 self.browser_steps) | ||||
|  | ||||
|             return valid_steps | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     def iterate_browser_steps(self): | ||||
|         from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface | ||||
|         from playwright._impl._api_types import TimeoutError | ||||
| @@ -170,10 +180,7 @@ class Fetcher(): | ||||
|         if self.browser_steps is not None and len(self.browser_steps): | ||||
|             interface = steppable_browser_interface() | ||||
|             interface.page = self.page | ||||
|  | ||||
|             valid_steps = filter( | ||||
|                 lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'), | ||||
|                 self.browser_steps) | ||||
|             valid_steps = self.browser_steps_get_valid_steps() | ||||
|  | ||||
|             for step in valid_steps: | ||||
|                 step_n += 1 | ||||
| @@ -464,39 +471,26 @@ class base_html_playwright(Fetcher): | ||||
|             if len(request_headers): | ||||
|                 context.set_extra_http_headers(request_headers) | ||||
|  | ||||
|                 self.page.set_default_navigation_timeout(90000) | ||||
|                 self.page.set_default_timeout(90000) | ||||
|             # Listen for all console events and handle errors | ||||
|             self.page.on("console", lambda msg: print(f"Playwright console: Watch URL: {url} {msg.type}: {msg.text} {msg.args}")) | ||||
|  | ||||
|                 # Listen for all console events and handle errors | ||||
|                 self.page.on("console", lambda msg: print(f"Playwright console: Watch URL: {url} {msg.type}: {msg.text} {msg.args}")) | ||||
|             # Re-use as much code from browser steps as possible so its the same | ||||
|             from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface | ||||
|             browsersteps_interface = steppable_browser_interface() | ||||
|             browsersteps_interface.page = self.page | ||||
|  | ||||
|             # Goto page | ||||
|             try: | ||||
|                 # Wait_until = commit | ||||
|                 # - `'commit'` - consider operation to be finished when network response is received and the document started loading. | ||||
|                 # Better to not use any smarts from Playwright and just wait an arbitrary number of seconds | ||||
|                 # This seemed to solve nearly all 'TimeoutErrors' | ||||
|                 response = self.page.goto(url, wait_until='commit') | ||||
|             except playwright._impl._api_types.Error as e: | ||||
|                 # Retry once - https://github.com/browserless/chrome/issues/2485 | ||||
|                 # Sometimes errors related to invalid cert's and other can be random | ||||
|                 print("Content Fetcher > retrying request got error - ", str(e)) | ||||
|                 time.sleep(1) | ||||
|                 response = self.page.goto(url, wait_until='commit') | ||||
|             except Exception as e: | ||||
|                 print("Content Fetcher > Other exception when page.goto", str(e)) | ||||
|             response = browsersteps_interface.action_goto_url(value=url) | ||||
|             self.headers = response.all_headers() | ||||
|  | ||||
|             if response is None: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 raise PageUnloadable(url=url, status_code=None, message=str(e)) | ||||
|                 print("Content Fetcher > Response object was none") | ||||
|                 raise EmptyReply(url=url, status_code=None) | ||||
|  | ||||
|             # Execute any browser steps | ||||
|             try: | ||||
|                 extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay | ||||
|                 self.page.wait_for_timeout(extra_wait * 1000) | ||||
|  | ||||
|                 if self.webdriver_js_execute_code is not None and len(self.webdriver_js_execute_code): | ||||
|                     self.page.evaluate(self.webdriver_js_execute_code) | ||||
|  | ||||
|                     browsersteps_interface.action_execute_js(value=self.webdriver_js_execute_code, selector=None) | ||||
|             except playwright._impl._api_types.TimeoutError as e: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
| @@ -508,28 +502,26 @@ class base_html_playwright(Fetcher): | ||||
|                 browser.close() | ||||
|                 raise PageUnloadable(url=url, status_code=None, message=str(e)) | ||||
|  | ||||
|             if response is None: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 print("Content Fetcher > Response object was none") | ||||
|                 raise EmptyReply(url=url, status_code=None) | ||||
|  | ||||
|             # Run Browser Steps here | ||||
|             self.iterate_browser_steps() | ||||
|  | ||||
|             extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay | ||||
|             time.sleep(extra_wait) | ||||
|             self.page.wait_for_timeout(extra_wait * 1000) | ||||
|  | ||||
|             self.content = self.page.content() | ||||
|             self.status_code = response.status | ||||
|  | ||||
|             if self.status_code != 200 and not ignore_status_codes: | ||||
|                 raise Non200ErrorCodeReceived(url=url, status_code=self.status_code) | ||||
|  | ||||
|             if len(self.page.content().strip()) == 0: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 print("Content Fetcher > Content was empty") | ||||
|                 raise EmptyReply(url=url, status_code=response.status) | ||||
|  | ||||
|             self.status_code = response.status | ||||
|             self.headers = response.all_headers() | ||||
|             # Run Browser Steps here | ||||
|             if self.browser_steps_get_valid_steps(): | ||||
|                 self.iterate_browser_steps() | ||||
|                  | ||||
|             self.page.wait_for_timeout(extra_wait * 1000) | ||||
|  | ||||
|             # So we can find an element on the page where its selector was entered manually (maybe not xPath etc) | ||||
|             if current_include_filters is not None: | ||||
| @@ -555,7 +547,7 @@ class base_html_playwright(Fetcher): | ||||
|             except Exception as e: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 raise ScreenshotUnavailable(url=url, status_code=None) | ||||
|                 raise ScreenshotUnavailable(url=url, status_code=response.status_code) | ||||
|  | ||||
|             context.close() | ||||
|             browser.close() | ||||
| @@ -614,14 +606,17 @@ class base_html_webdriver(Fetcher): | ||||
|             is_binary=False): | ||||
|  | ||||
|         from selenium import webdriver | ||||
|         from selenium.webdriver.common.desired_capabilities import DesiredCapabilities | ||||
|         from selenium.webdriver.chrome.options import Options as ChromeOptions | ||||
|         from selenium.common.exceptions import WebDriverException | ||||
|         # request_body, request_method unused for now, until some magic in the future happens. | ||||
|  | ||||
|         options = ChromeOptions() | ||||
|         if self.proxy: | ||||
|             options.proxy = self.proxy | ||||
|  | ||||
|         self.driver = webdriver.Remote( | ||||
|             command_executor=self.command_executor, | ||||
|             desired_capabilities=DesiredCapabilities.CHROME, | ||||
|             proxy=self.proxy) | ||||
|             options=options) | ||||
|  | ||||
|         try: | ||||
|             self.driver.get(url) | ||||
| @@ -653,11 +648,11 @@ class base_html_webdriver(Fetcher): | ||||
|     # Does the connection to the webdriver work? run a test connection. | ||||
|     def is_ready(self): | ||||
|         from selenium import webdriver | ||||
|         from selenium.webdriver.common.desired_capabilities import DesiredCapabilities | ||||
|         from selenium.webdriver.chrome.options import Options as ChromeOptions | ||||
|  | ||||
|         self.driver = webdriver.Remote( | ||||
|             command_executor=self.command_executor, | ||||
|             desired_capabilities=DesiredCapabilities.CHROME) | ||||
|             options=ChromeOptions()) | ||||
|  | ||||
|         # driver.quit() seems to cause better exceptions | ||||
|         self.quit() | ||||
|   | ||||
| @@ -22,7 +22,8 @@ from wtforms.validators import ValidationError | ||||
| # each select <option data-enabled="enabled-0-0" | ||||
| from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config | ||||
|  | ||||
| from changedetectionio import content_fetcher | ||||
| from changedetectionio import content_fetcher, html_tools | ||||
|  | ||||
| from changedetectionio.notification import ( | ||||
|     valid_notification_formats, | ||||
| ) | ||||
| @@ -284,11 +285,10 @@ class ValidateListRegex(object): | ||||
|     def __call__(self, form, field): | ||||
|  | ||||
|         for line in field.data: | ||||
|             if line[0] == '/' and line[-1] == '/': | ||||
|                 # Because internally we dont wrap in / | ||||
|                 line = line.strip('/') | ||||
|             if re.search(html_tools.PERL_STYLE_REGEX, line, re.IGNORECASE): | ||||
|                 try: | ||||
|                     re.compile(line) | ||||
|                     regex = html_tools.perl_style_slash_enclosed_regex_to_options(line) | ||||
|                     re.compile(regex) | ||||
|                 except re.error: | ||||
|                     message = field.gettext('RegEx \'%s\' is not a valid regular expression.') | ||||
|                     raise ValidationError(message % (line)) | ||||
|   | ||||
| @@ -1,9 +1,12 @@ | ||||
|  | ||||
| from bs4 import BeautifulSoup | ||||
| from inscriptis import get_text | ||||
| from inscriptis.model.config import ParserConfig | ||||
| from jsonpath_ng.ext import parse | ||||
| from typing import List | ||||
| from inscriptis.css_profiles import CSS_PROFILES, HtmlElement | ||||
| from inscriptis.html_properties import Display | ||||
| from inscriptis.model.config import ParserConfig | ||||
| from xml.sax.saxutils import escape as xml_escape | ||||
| import json | ||||
| import re | ||||
|  | ||||
| @@ -68,10 +71,15 @@ def element_removal(selectors: List[str], html_content): | ||||
|  | ||||
|  | ||||
| # Return str Utf-8 of matched rules | ||||
| def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False): | ||||
| def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False, is_rss=False): | ||||
|     from lxml import etree, html | ||||
|  | ||||
|     tree = html.fromstring(bytes(html_content, encoding='utf-8')) | ||||
|     parser = None | ||||
|     if is_rss: | ||||
|         # So that we can keep CDATA for cdata_in_document_to_text() to process | ||||
|         parser = etree.XMLParser(strip_cdata=False) | ||||
|  | ||||
|     tree = html.fromstring(bytes(html_content, encoding='utf-8'), parser=parser) | ||||
|     html_block = "" | ||||
|  | ||||
|     r = tree.xpath(xpath_filter.strip(), namespaces={'re': 'http://exslt.org/regular-expressions'}) | ||||
| @@ -94,7 +102,6 @@ def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False | ||||
|  | ||||
|     return html_block | ||||
|  | ||||
|  | ||||
| # Extract/find element | ||||
| def extract_element(find='title', html_content=''): | ||||
|  | ||||
| @@ -260,8 +267,15 @@ def strip_ignore_text(content, wordlist, mode="content"): | ||||
|  | ||||
|     return "\n".encode('utf8').join(output) | ||||
|  | ||||
| def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str: | ||||
|     pattern = '<!\[CDATA\[(\s*(?:.(?<!\]\]>)\s*)*)\]\]>' | ||||
|     def repl(m): | ||||
|         text = m.group(1) | ||||
|         return xml_escape(html_to_text(html_content=text)).strip() | ||||
|  | ||||
| def html_to_text(html_content: str, render_anchor_tag_content=False) -> str: | ||||
|     return re.sub(pattern, repl, html_content) | ||||
|  | ||||
| def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False) -> str: | ||||
|     """Converts html string to a string with just the text. If ignoring | ||||
|     rendering anchor tag content is enable, anchor tag content are also | ||||
|     included in the text | ||||
| @@ -277,16 +291,21 @@ def html_to_text(html_content: str, render_anchor_tag_content=False) -> str: | ||||
|     #  if anchor tag content flag is set to True define a config for | ||||
|     #  extracting this content | ||||
|     if render_anchor_tag_content: | ||||
|  | ||||
|         parser_config = ParserConfig( | ||||
|             annotation_rules={"a": ["hyperlink"]}, display_links=True | ||||
|             annotation_rules={"a": ["hyperlink"]}, | ||||
|             display_links=True | ||||
|         ) | ||||
|  | ||||
|     # otherwise set config to None | ||||
|     # otherwise set config to None/default | ||||
|     else: | ||||
|         parser_config = None | ||||
|  | ||||
|     # get text and annotations via inscriptis | ||||
|     # RSS Mode - Inscriptis will treat `title` as something else. | ||||
|     # Make it as a regular block display element (//item/title) | ||||
|     # This is a bit of a hack - the real way it to use XSLT to convert it to HTML #1874 | ||||
|     if is_rss: | ||||
|         html_content = re.sub(r'<title([\s>])', r'<h1\1', html_content) | ||||
|         html_content = re.sub(r'</title>', r'</h1>', html_content) | ||||
|  | ||||
|     text_content = get_text(html_content, config=parser_config) | ||||
|  | ||||
|     return text_content | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import os | ||||
| import re | ||||
| import time | ||||
| import uuid | ||||
| from pathlib import Path | ||||
|  | ||||
| # Allowable protocols, protects against javascript: etc | ||||
| # file:// is further checked by ALLOW_FILE_URI | ||||
| @@ -18,6 +19,7 @@ from changedetectionio.notification import ( | ||||
|  | ||||
| base_config = { | ||||
|     'body': None, | ||||
|     'browser_steps_last_error_step': None, | ||||
|     'check_unique_lines': False,  # On change-detected, compare against all history if its something new | ||||
|     'check_count': 0, | ||||
|     'date_created': None, | ||||
| @@ -25,6 +27,7 @@ base_config = { | ||||
|     'extract_text': [],  # Extract text by regex after filters | ||||
|     'extract_title_as_title': False, | ||||
|     'fetch_backend': 'system', # plaintext, playwright etc | ||||
|     'fetch_time': 0.0, | ||||
|     'processor': 'text_json_diff', # could be restock_diff or others from .processors | ||||
|     'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')), | ||||
|     'filter_text_added': True, | ||||
| @@ -167,9 +170,7 @@ class model(dict): | ||||
|     @property | ||||
|     def label(self): | ||||
|         # Used for sorting | ||||
|         if self['title']: | ||||
|             return self['title'] | ||||
|         return self['url'] | ||||
|         return self.get('title') if self.get('title') else self.get('url') | ||||
|  | ||||
|     @property | ||||
|     def last_changed(self): | ||||
| @@ -491,3 +492,13 @@ class model(dict): | ||||
|         filepath = os.path.join(self.watch_data_dir, 'last-fetched.br') | ||||
|         with open(filepath, 'wb') as f: | ||||
|             f.write(brotli.compress(contents, mode=brotli.MODE_TEXT)) | ||||
|  | ||||
|     @property | ||||
|     def get_browsersteps_available_screenshots(self): | ||||
|         "For knowing which screenshots are available to show the user in BrowserSteps UI" | ||||
|         available = [] | ||||
|         for f in Path(self.watch_data_dir).glob('step_before-*.jpeg'): | ||||
|             step_n=re.search(r'step_before-(\d+)', f.name) | ||||
|             if step_n: | ||||
|                 available.append(step_n.group(1)) | ||||
|         return available | ||||
|   | ||||
| @@ -11,7 +11,7 @@ from changedetectionio import content_fetcher, html_tools | ||||
| from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT | ||||
| from copy import deepcopy | ||||
| from . import difference_detection_processor | ||||
| from ..html_tools import PERL_STYLE_REGEX | ||||
| from ..html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text | ||||
|  | ||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
|  | ||||
| @@ -153,13 +153,22 @@ class perform_site_check(difference_detection_processor): | ||||
|  | ||||
|         is_json = 'application/json' in fetcher.get_all_headers().get('content-type', '').lower() | ||||
|         is_html = not is_json | ||||
|         is_rss = False | ||||
|  | ||||
|         ctype_header = fetcher.get_all_headers().get('content-type', '').lower() | ||||
|         # Go into RSS preprocess for converting CDATA/comment to usable text | ||||
|         if any(substring in ctype_header for substring in ['application/xml', 'application/rss', 'text/xml']): | ||||
|             if '<rss' in fetcher.content[:100].lower(): | ||||
|                 fetcher.content = cdata_in_document_to_text(html_content=fetcher.content) | ||||
|                 is_rss = True | ||||
|  | ||||
|         # source: support, basically treat it as plaintext | ||||
|         if is_source: | ||||
|             is_html = False | ||||
|             is_json = False | ||||
|  | ||||
|         if watch.is_pdf or 'application/pdf' in fetcher.get_all_headers().get('content-type', '').lower(): | ||||
|         inline_pdf = fetcher.get_all_headers().get('content-disposition', '') and '%PDF-1' in fetcher.content[:10] | ||||
|         if watch.is_pdf or 'application/pdf' in fetcher.get_all_headers().get('content-type', '').lower() or inline_pdf: | ||||
|             from shutil import which | ||||
|             tool = os.getenv("PDF_TO_HTML_TOOL", "pdftohtml") | ||||
|             if not which(tool): | ||||
| @@ -242,7 +251,8 @@ class perform_site_check(difference_detection_processor): | ||||
|                         if filter_rule[0] == '/' or filter_rule.startswith('xpath:'): | ||||
|                             html_content += html_tools.xpath_filter(xpath_filter=filter_rule.replace('xpath:', ''), | ||||
|                                                                     html_content=fetcher.content, | ||||
|                                                                     append_pretty_line_formatting=not is_source) | ||||
|                                                                     append_pretty_line_formatting=not is_source, | ||||
|                                                                     is_rss=is_rss) | ||||
|                         else: | ||||
|                             # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text | ||||
|                             html_content += html_tools.include_filters(include_filters=filter_rule, | ||||
| @@ -262,8 +272,9 @@ class perform_site_check(difference_detection_processor): | ||||
|                     do_anchor = self.datastore.data["settings"]["application"].get("render_anchor_tag_content", False) | ||||
|                     stripped_text_from_html = \ | ||||
|                         html_tools.html_to_text( | ||||
|                             html_content, | ||||
|                             render_anchor_tag_content=do_anchor | ||||
|                             html_content=html_content, | ||||
|                             render_anchor_tag_content=do_anchor, | ||||
|                             is_rss=is_rss # #1874 activate the <title workaround hack | ||||
|                         ) | ||||
|  | ||||
|         # Re #340 - return the content before the 'ignore text' was applied | ||||
|   | ||||
| @@ -321,8 +321,14 @@ $(document).ready(function () { | ||||
|             var s = '<div class="control">' + '<a data-step-index=' + i + ' class="pure-button button-secondary button-green button-xsmall apply" >Apply</a> '; | ||||
|             if (i > 0) { | ||||
|                 // The first step never gets these (Goto-site) | ||||
|                 s += '<a data-step-index=' + i + ' class="pure-button button-secondary button-xsmall clear" >Clear</a> ' + | ||||
|                     '<a data-step-index=' + i + ' class="pure-button button-secondary button-red button-xsmall remove" >Remove</a>'; | ||||
|                 s += `<a data-step-index="${i}" class="pure-button button-secondary button-xsmall clear" >Clear</a> ` + | ||||
|                     `<a data-step-index="${i}" class="pure-button button-secondary button-red button-xsmall remove" >Remove</a>`; | ||||
|  | ||||
|                 // if a screenshot is available | ||||
|                 if (browser_steps_available_screenshots.includes(i.toString())) { | ||||
|                     var d = (browser_steps_last_error_step === i+1) ? 'before' : 'after'; | ||||
|                     s += ` <a data-step-index="${i}" class="pure-button button-secondary button-xsmall show-screenshot" title="Show screenshot from last run" data-type="${d}">Pic</a> `; | ||||
|                 } | ||||
|             } | ||||
|             s += '</div>'; | ||||
|             $(this).append(s) | ||||
| @@ -437,6 +443,24 @@ $(document).ready(function () { | ||||
|  | ||||
|     }); | ||||
|  | ||||
|     $('ul#browser_steps li .control .show-screenshot').click(function (element) { | ||||
|         var step_n = $(event.currentTarget).data('step-index'); | ||||
|         w = window.open(this.href, "_blank", "width=640,height=480"); | ||||
|         const t = $(event.currentTarget).data('type'); | ||||
|  | ||||
|         const url = browser_steps_fetch_screenshot_image_url + `&step_n=${step_n}&type=${t}`; | ||||
|         w.document.body.innerHTML = `<!DOCTYPE html> | ||||
|             <html lang="en"> | ||||
|                 <body> | ||||
|                     <img src="${url}" style="width: 100%" alt="Browser Step at step ${step_n} from last run." title="Browser Step at step ${step_n} from last run."/> | ||||
|                 </body> | ||||
|         </html>`; | ||||
|         w.document.title = `Browser Step at step ${step_n} from last run.`; | ||||
|     }); | ||||
|  | ||||
|     if (browser_steps_last_error_step) { | ||||
|         $("ul#browser_steps>li:nth-child("+browser_steps_last_error_step+")").addClass("browser-step-with-error"); | ||||
|     } | ||||
|  | ||||
|     $("ul#browser_steps select").change(function () { | ||||
|         set_greyed_state(); | ||||
|   | ||||
| @@ -1,110 +1,120 @@ | ||||
| var a = document.getElementById("a"); | ||||
| var b = document.getElementById("b"); | ||||
| var result = document.getElementById("result"); | ||||
| $(document).ready(function () { | ||||
|     var a = document.getElementById("a"); | ||||
|     var b = document.getElementById("b"); | ||||
|     var result = document.getElementById("result"); | ||||
|     var inputs; | ||||
|  | ||||
| function changed() { | ||||
|   // https://github.com/kpdecker/jsdiff/issues/389 | ||||
|   // I would love to use `{ignoreWhitespace: true}` here but it breaks the formatting | ||||
|   options = { | ||||
|     ignoreWhitespace: document.getElementById("ignoreWhitespace").checked, | ||||
|   }; | ||||
|     $('#jump-next-diff').click(function () { | ||||
|  | ||||
|   var diff = Diff[window.diffType](a.textContent, b.textContent, options); | ||||
|   var fragment = document.createDocumentFragment(); | ||||
|   for (var i = 0; i < diff.length; i++) { | ||||
|     if (diff[i].added && diff[i + 1] && diff[i + 1].removed) { | ||||
|       var swap = diff[i]; | ||||
|       diff[i] = diff[i + 1]; | ||||
|       diff[i + 1] = swap; | ||||
|         var element = inputs[inputs.current]; | ||||
|         var headerOffset = 80; | ||||
|         var elementPosition = element.getBoundingClientRect().top; | ||||
|         var offsetPosition = elementPosition - headerOffset + window.scrollY; | ||||
|  | ||||
|         window.scrollTo({ | ||||
|             top: offsetPosition, | ||||
|             behavior: "smooth", | ||||
|         }); | ||||
|  | ||||
|         inputs.current++; | ||||
|         if (inputs.current >= inputs.length) { | ||||
|             inputs.current = 0; | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     function changed() { | ||||
|         // https://github.com/kpdecker/jsdiff/issues/389 | ||||
|         // I would love to use `{ignoreWhitespace: true}` here but it breaks the formatting | ||||
|         options = { | ||||
|             ignoreWhitespace: document.getElementById("ignoreWhitespace").checked, | ||||
|         }; | ||||
|  | ||||
|         var diff = Diff[window.diffType](a.textContent, b.textContent, options); | ||||
|         var fragment = document.createDocumentFragment(); | ||||
|         for (var i = 0; i < diff.length; i++) { | ||||
|             if (diff[i].added && diff[i + 1] && diff[i + 1].removed) { | ||||
|                 var swap = diff[i]; | ||||
|                 diff[i] = diff[i + 1]; | ||||
|                 diff[i + 1] = swap; | ||||
|             } | ||||
|  | ||||
|             var node; | ||||
|             if (diff[i].removed) { | ||||
|                 node = document.createElement("del"); | ||||
|                 node.classList.add("change"); | ||||
|                 const wrapper = node.appendChild(document.createElement("span")); | ||||
|                 wrapper.appendChild(document.createTextNode(diff[i].value)); | ||||
|             } else if (diff[i].added) { | ||||
|                 node = document.createElement("ins"); | ||||
|                 node.classList.add("change"); | ||||
|                 const wrapper = node.appendChild(document.createElement("span")); | ||||
|                 wrapper.appendChild(document.createTextNode(diff[i].value)); | ||||
|             } else { | ||||
|                 node = document.createTextNode(diff[i].value); | ||||
|             } | ||||
|             fragment.appendChild(node); | ||||
|         } | ||||
|  | ||||
|         result.textContent = ""; | ||||
|         result.appendChild(fragment); | ||||
|  | ||||
|         // For nice mouse-over hover/title information | ||||
|         const removed_current_option = $('#diff-version option:selected') | ||||
|         if (removed_current_option) { | ||||
|             $('del').each(function () { | ||||
|                 $(this).prop('title', 'Removed '+removed_current_option[0].label); | ||||
|             }); | ||||
|         } | ||||
|         const inserted_current_option = $('#current-version option:selected') | ||||
|         if (removed_current_option) { | ||||
|             $('ins').each(function () { | ||||
|                 $(this).prop('title', 'Inserted '+inserted_current_option[0].label); | ||||
|             }); | ||||
|         } | ||||
|         // Set the list of possible differences to jump to | ||||
|         inputs = document.querySelectorAll('#diff-ui .change') | ||||
|         // Set the "current" diff pointer | ||||
|         inputs.current = 0; | ||||
|         // Goto diff | ||||
|         $('#jump-next-diff').click(); | ||||
|     } | ||||
|  | ||||
|     var node; | ||||
|     if (diff[i].removed) { | ||||
|       node = document.createElement("del"); | ||||
|       node.classList.add("change"); | ||||
|       const wrapper = node.appendChild(document.createElement("span")); | ||||
|       wrapper.appendChild(document.createTextNode(diff[i].value)); | ||||
|     } else if (diff[i].added) { | ||||
|       node = document.createElement("ins"); | ||||
|       node.classList.add("change"); | ||||
|       const wrapper = node.appendChild(document.createElement("span")); | ||||
|       wrapper.appendChild(document.createTextNode(diff[i].value)); | ||||
|     } else { | ||||
|       node = document.createTextNode(diff[i].value); | ||||
|     } | ||||
|     fragment.appendChild(node); | ||||
|   } | ||||
|  | ||||
|   result.textContent = ""; | ||||
|   result.appendChild(fragment); | ||||
|  | ||||
|   // Jump at start | ||||
|   inputs.current = 0; | ||||
|   next_diff(); | ||||
| } | ||||
|  | ||||
| window.onload = function () { | ||||
|   /* Convert what is options from UTC time.time() to local browser time */ | ||||
|   var diffList = document.getElementById("diff-version"); | ||||
|   if (typeof diffList != "undefined" && diffList != null) { | ||||
|     for (var option of diffList.options) { | ||||
|       var dateObject = new Date(option.value * 1000); | ||||
|       option.label = dateObject.toLocaleString(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /* Set current version date as local time in the browser also */ | ||||
|   var current_v = document.getElementById("current-v-date"); | ||||
|   var dateObject = new Date(newest_version_timestamp * 1000); | ||||
|   current_v.innerHTML = dateObject.toLocaleString(); | ||||
|   onDiffTypeChange( | ||||
|     document.querySelector('#settings [name="diff_type"]:checked'), | ||||
|   ); | ||||
|   changed(); | ||||
| }; | ||||
|  | ||||
| a.onpaste = a.onchange = b.onpaste = b.onchange = changed; | ||||
|  | ||||
| if ("oninput" in a) { | ||||
|   a.oninput = b.oninput = changed; | ||||
| } else { | ||||
|   a.onkeyup = b.onkeyup = changed; | ||||
| } | ||||
|  | ||||
| function onDiffTypeChange(radio) { | ||||
|   window.diffType = radio.value; | ||||
|   // Not necessary | ||||
|   //	document.title = "Diff " + radio.value.slice(4); | ||||
| } | ||||
|  | ||||
| var radio = document.getElementsByName("diff_type"); | ||||
| for (var i = 0; i < radio.length; i++) { | ||||
|   radio[i].onchange = function (e) { | ||||
|     onDiffTypeChange(e.target); | ||||
|     $('.needs-localtime').each(function () { | ||||
|         for (var option of this.options) { | ||||
|             var dateObject = new Date(option.value * 1000); | ||||
|             option.label = dateObject.toLocaleString(undefined, {dateStyle: "full", timeStyle: "medium"}); | ||||
|         } | ||||
|     }) | ||||
|     onDiffTypeChange( | ||||
|         document.querySelector('#settings [name="diff_type"]:checked'), | ||||
|     ); | ||||
|     changed(); | ||||
|   }; | ||||
| } | ||||
|  | ||||
| document.getElementById("ignoreWhitespace").onchange = function (e) { | ||||
|   changed(); | ||||
| }; | ||||
|     a.onpaste = a.onchange = b.onpaste = b.onchange = changed; | ||||
|  | ||||
| var inputs = document.getElementsByClassName("change"); | ||||
| inputs.current = 0; | ||||
|     if ("oninput" in a) { | ||||
|         a.oninput = b.oninput = changed; | ||||
|     } else { | ||||
|         a.onkeyup = b.onkeyup = changed; | ||||
|     } | ||||
|  | ||||
| function next_diff() { | ||||
|   var element = inputs[inputs.current]; | ||||
|   var headerOffset = 80; | ||||
|   var elementPosition = element.getBoundingClientRect().top; | ||||
|   var offsetPosition = elementPosition - headerOffset + window.scrollY; | ||||
|     function onDiffTypeChange(radio) { | ||||
|         window.diffType = radio.value; | ||||
|         // Not necessary | ||||
|         //	document.title = "Diff " + radio.value.slice(4); | ||||
|     } | ||||
|  | ||||
|   window.scrollTo({ | ||||
|     top: offsetPosition, | ||||
|     behavior: "smooth", | ||||
|   }); | ||||
|     var radio = document.getElementsByName("diff_type"); | ||||
|     for (var i = 0; i < radio.length; i++) { | ||||
|         radio[i].onchange = function (e) { | ||||
|             onDiffTypeChange(e.target); | ||||
|             changed(); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     document.getElementById("ignoreWhitespace").onchange = function (e) { | ||||
|         changed(); | ||||
|     }; | ||||
|  | ||||
| }); | ||||
|  | ||||
|   inputs.current++; | ||||
|   if (inputs.current >= inputs.length) { | ||||
|     inputs.current = 0; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,6 +4,14 @@ $(function () { | ||||
|         $(this).closest('.unviewed').removeClass('unviewed'); | ||||
|     }); | ||||
|  | ||||
|     $('td[data-timestamp]').each(function () { | ||||
|         $(this).prop('title', new Intl.DateTimeFormat(undefined, | ||||
|             { | ||||
|                 dateStyle: 'full', | ||||
|                 timeStyle: 'long' | ||||
|             }).format($(this).data('timestamp') * 1000)); | ||||
|     }) | ||||
|  | ||||
|     $("#checkbox-assign-tag").click(function (e) { | ||||
|         $('#op_extradata').val(prompt("Enter a tag name")); | ||||
|     }); | ||||
|   | ||||
| @@ -187,6 +187,10 @@ ins { | ||||
|     padding: 0.5em; } | ||||
|   #settings ins { | ||||
|     padding: 0.5em; } | ||||
|   #settings option:checked { | ||||
|     font-weight: bold; } | ||||
|   #settings [type=radio], #settings [type=checkbox] { | ||||
|     vertical-align: middle; } | ||||
|  | ||||
| .source { | ||||
|   position: absolute; | ||||
|   | ||||
| @@ -77,6 +77,13 @@ ins { | ||||
|   ins { | ||||
|     padding: 0.5em; | ||||
|   } | ||||
|  | ||||
|   option:checked { | ||||
|     font-weight: bold; | ||||
|   } | ||||
|   [type=radio],[type=checkbox] { | ||||
|     vertical-align: middle; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .source { | ||||
|   | ||||
| @@ -6,6 +6,10 @@ | ||||
|   } | ||||
|  | ||||
|   li { | ||||
|     &.browser-step-with-error { | ||||
|       background-color: #ffd6d6; | ||||
|       border-radius: 4px; | ||||
|     } | ||||
|     &:not(:first-child) { | ||||
|       &:hover { | ||||
|         opacity: 1.0; | ||||
|   | ||||
| @@ -0,0 +1,28 @@ | ||||
|  | ||||
| #selector-wrapper { | ||||
|   height: 100%; | ||||
|   max-height: 70vh; | ||||
|   overflow-y: scroll; | ||||
|   position: relative; | ||||
|  | ||||
|   //width: 100%; | ||||
|   >img { | ||||
|     position: absolute; | ||||
|     z-index: 4; | ||||
|     max-width: 100%; | ||||
|   } | ||||
|  | ||||
|   >canvas { | ||||
|     position: relative; | ||||
|     z-index: 5; | ||||
|     max-width: 100%; | ||||
|  | ||||
|     &:hover { | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| #selector-current-xpath { | ||||
|   font-size: 80%; | ||||
| } | ||||
| @@ -471,7 +471,11 @@ footer { | ||||
|   padding: 10px; | ||||
|  | ||||
|   &#left-sticky { | ||||
|     left: 0px; | ||||
|     left: 0; | ||||
|     position: fixed; | ||||
|     border-top-right-radius: 5px; | ||||
|     border-bottom-right-radius: 5px; | ||||
|     box-shadow: 1px 1px 4px var(--color-shadow-jump); | ||||
|   } | ||||
|  | ||||
|   &#right-sticky { | ||||
| @@ -939,32 +943,7 @@ ul { | ||||
|   } | ||||
| } | ||||
|  | ||||
| #selector-wrapper { | ||||
|   height: 100%; | ||||
|   overflow-y: scroll; | ||||
|   position: relative; | ||||
|  | ||||
|   //width: 100%; | ||||
|   >img { | ||||
|     position: absolute; | ||||
|     z-index: 4; | ||||
|     max-width: 100%; | ||||
|   } | ||||
|  | ||||
|   >canvas { | ||||
|     position: relative; | ||||
|     z-index: 5; | ||||
|     max-width: 100%; | ||||
|  | ||||
|     &:hover { | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| #selector-current-xpath { | ||||
|   font-size: 80%; | ||||
| } | ||||
| @import "parts/_visualselector"; | ||||
|  | ||||
| #webdriver-override-options { | ||||
|   input[type="number"] { | ||||
|   | ||||
| @@ -26,6 +26,9 @@ | ||||
|   #browser_steps li { | ||||
|     list-style: decimal; | ||||
|     padding: 5px; } | ||||
|     #browser_steps li.browser-step-with-error { | ||||
|       background-color: #ffd6d6; | ||||
|       border-radius: 4px; } | ||||
|     #browser_steps li:not(:first-child):hover { | ||||
|       opacity: 1.0; } | ||||
|     #browser_steps li .control { | ||||
| @@ -667,7 +670,11 @@ footer { | ||||
|   background: var(--color-background); | ||||
|   padding: 10px; } | ||||
|   .sticky-tab#left-sticky { | ||||
|     left: 0px; } | ||||
|     left: 0; | ||||
|     position: fixed; | ||||
|     border-top-right-radius: 5px; | ||||
|     border-bottom-right-radius: 5px; | ||||
|     box-shadow: 1px 1px 4px var(--color-shadow-jump); } | ||||
|   .sticky-tab#right-sticky { | ||||
|     right: 0px; } | ||||
|   .sticky-tab#hosted-sticky { | ||||
| @@ -976,6 +983,7 @@ ul { | ||||
|  | ||||
| #selector-wrapper { | ||||
|   height: 100%; | ||||
|   max-height: 70vh; | ||||
|   overflow-y: scroll; | ||||
|   position: relative; } | ||||
|   #selector-wrapper > img { | ||||
|   | ||||
| @@ -96,6 +96,14 @@ class ChangeDetectionStore: | ||||
|                 self.add_watch(url='https://changedetection.io/CHANGELOG.txt', | ||||
|                                tag='changedetection.io', | ||||
|                                extras={'fetch_backend': 'html_requests'}) | ||||
|  | ||||
|             updates_available = self.get_updates_available() | ||||
|             self.__data['settings']['application']['schema_version'] = updates_available.pop() | ||||
|  | ||||
|         else: | ||||
|             # Bump the update version by running updates | ||||
|             self.run_updates() | ||||
|  | ||||
|         self.__data['version_tag'] = version_tag | ||||
|  | ||||
|         # Just to test that proxies.json if it exists, doesnt throw a parsing error on startup | ||||
| @@ -125,9 +133,6 @@ class ChangeDetectionStore: | ||||
|             secret = secrets.token_hex(16) | ||||
|             self.__data['settings']['application']['api_access_token'] = secret | ||||
|  | ||||
|         # Bump the update version by running updates | ||||
|         self.run_updates() | ||||
|  | ||||
|         self.needs_write = True | ||||
|  | ||||
|         # Finally start the thread that will manage periodic data saves to JSON | ||||
| @@ -239,12 +244,16 @@ class ChangeDetectionStore: | ||||
|         import pathlib | ||||
|  | ||||
|         self.__data['watching'][uuid].update({ | ||||
|                 'last_checked': 0, | ||||
|                 'browser_steps_last_error_step' : None, | ||||
|                 'check_count': 0, | ||||
|                 'fetch_time' : 0.0, | ||||
|                 'has_ldjson_price_data': None, | ||||
|                 'last_checked': 0, | ||||
|                 'last_error': False, | ||||
|                 'last_notification_error': False, | ||||
|                 'last_viewed': 0, | ||||
|                 'previous_md5': False, | ||||
|                 'previous_md5_before_filters': False, | ||||
|                 'track_ldjson_price_data': None, | ||||
|             }) | ||||
|  | ||||
| @@ -625,14 +634,8 @@ class ChangeDetectionStore: | ||||
|     def tag_exists_by_name(self, tag_name): | ||||
|         return any(v.get('title', '').lower() == tag_name.lower() for k, v in self.__data['settings']['application']['tags'].items()) | ||||
|  | ||||
|     # Run all updates | ||||
|     # IMPORTANT - Each update could be run even when they have a new install and the schema is correct | ||||
|     #             So therefor - each `update_n` should be very careful about checking if it needs to actually run | ||||
|     #             Probably we should bump the current update schema version with each tag release version? | ||||
|     def run_updates(self): | ||||
|     def get_updates_available(self): | ||||
|         import inspect | ||||
|         import shutil | ||||
|  | ||||
|         updates_available = [] | ||||
|         for i, o in inspect.getmembers(self, predicate=inspect.ismethod): | ||||
|             m = re.search(r'update_(\d+)$', i) | ||||
| @@ -640,6 +643,15 @@ class ChangeDetectionStore: | ||||
|                 updates_available.append(int(m.group(1))) | ||||
|         updates_available.sort() | ||||
|  | ||||
|         return updates_available | ||||
|  | ||||
|     # Run all updates | ||||
|     # IMPORTANT - Each update could be run even when they have a new install and the schema is correct | ||||
|     #             So therefor - each `update_n` should be very careful about checking if it needs to actually run | ||||
|     #             Probably we should bump the current update schema version with each tag release version? | ||||
|     def run_updates(self): | ||||
|         import shutil | ||||
|         updates_available = self.get_updates_available() | ||||
|         for update_n in updates_available: | ||||
|             if update_n > self.__data['settings']['application']['schema_version']: | ||||
|                 print ("Applying update_{}".format((update_n))) | ||||
|   | ||||
| @@ -85,6 +85,7 @@ | ||||
|               <a href="{{url_for('logout')}}" class="pure-menu-link">LOG OUT</a> | ||||
|             </li> | ||||
|           {% endif %} | ||||
|           {% if current_user.is_authenticated or not has_password %} | ||||
|           <li class="pure-menu-item pure-form" id="search-menu-item"> | ||||
|             <!-- We use GET here so it offers people a chance to set bookmarks etc --> | ||||
|             <form name="searchForm" action="" method="GET"> | ||||
| @@ -95,6 +96,7 @@ | ||||
|               </button> | ||||
|             </form> | ||||
|           </li> | ||||
|           {% endif %} | ||||
|           <li class="pure-menu-item"> | ||||
|             <button class="toggle-button" id ="toggle-light-mode" type="button" title="Toggle Light/Dark Mode"> | ||||
|               <span class="visually-hidden">Toggle light/dark mode</span> | ||||
| @@ -121,7 +123,8 @@ | ||||
|     {% endif %} | ||||
|     {% if left_sticky %} | ||||
|       <div class="sticky-tab" id="left-sticky"> | ||||
|         <a href="{{url_for('preview_page', uuid=uuid)}}">Show current snapshot</a> | ||||
|         <a href="{{url_for('preview_page', uuid=uuid)}}">Show current snapshot</a><br> | ||||
|           Visualise <strong>triggers</strong> and <strong>ignored text</strong> | ||||
|       </div> | ||||
|     {% endif %} | ||||
|     {% if right_sticky %} | ||||
|   | ||||
| @@ -13,10 +13,31 @@ | ||||
| <script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> | ||||
|  | ||||
| <div id="settings"> | ||||
|     <h1>Differences</h1> | ||||
|     <form class="pure-form " action="" method="GET"> | ||||
|         <fieldset> | ||||
|  | ||||
|             {% if versions|length >= 1 %} | ||||
|                 <strong>Compare</strong> | ||||
|                 <del class="change"><span>from</span></del> | ||||
|                 <select id="diff-version" name="from_version" class="needs-localtime"> | ||||
|                     {% for version in versions|reverse %} | ||||
|                         <option value="{{ version }}" {% if version== from_version %} selected="" {% endif %}> | ||||
|                             {{ version }} | ||||
|                         </option> | ||||
|                     {% endfor %} | ||||
|                 </select> | ||||
|                 <ins class="change"><span>to</span></ins> | ||||
|                 <select id="current-version" name="to_version" class="needs-localtime"> | ||||
|                     {% for version in versions|reverse %} | ||||
|                         <option value="{{ version }}" {% if version== to_version %} selected="" {% endif %}> | ||||
|                             {{ version }} | ||||
|                         </option> | ||||
|                     {% endfor %} | ||||
|                 </select> | ||||
|                 <button type="submit" class="pure-button pure-button-primary">Go</button> | ||||
|             {% endif %} | ||||
|         </fieldset> | ||||
|         <fieldset> | ||||
|             <strong>Style</strong> | ||||
|             <label for="diffWords" class="pure-checkbox"> | ||||
|                 <input type="radio" name="diff_type" id="diffWords" value="diffWords"> Words</label> | ||||
|             <label for="diffLines" class="pure-checkbox"> | ||||
| @@ -26,32 +47,20 @@ | ||||
|                 <input type="radio" name="diff_type" id="diffChars" value="diffChars"> Chars</label> | ||||
|             <!-- @todo - when mimetype is JSON, select this by default? --> | ||||
|             <label for="diffJson" class="pure-checkbox"> | ||||
|                 <input type="radio" name="diff_type" id="diffJson" value="diffJson" > JSON</label> | ||||
|                 <input type="radio" name="diff_type" id="diffJson" value="diffJson"> JSON</label> | ||||
|  | ||||
|             {% if versions|length >= 1 %} | ||||
|             <label for="diff-version">Compare newest (<span id="current-v-date"></span>) with</label> | ||||
|             <select id="diff-version" name="previous_version"> | ||||
|                 {% for version in versions|reverse %} | ||||
|                 <option value="{{version}}" {% if version== current_previous_version %} selected="" {% endif %}> | ||||
|                     {{version}} | ||||
|                 </option> | ||||
|                 {% endfor %} | ||||
|             </select> | ||||
|             <button type="submit" class="pure-button pure-button-primary">Go</button> | ||||
|             {% endif %} | ||||
|         </fieldset> | ||||
|     </form> | ||||
|     <del>Removed text</del> | ||||
|     <ins>Inserted Text</ins> | ||||
|     <span> | ||||
|             <span> | ||||
|         <!-- https://github.com/kpdecker/jsdiff/issues/389 ? --> | ||||
|         <label for="ignoreWhitespace" class="pure-checkbox" id="label-diff-ignorewhitespace"> | ||||
|             <input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace" > Ignore Whitespace</label> | ||||
|             <input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace"> Ignore Whitespace</label> | ||||
|     </span> | ||||
|         </fieldset> | ||||
|     </form> | ||||
|  | ||||
| </div> | ||||
|  | ||||
| <div id="diff-jump"> | ||||
|     <a onclick="next_diff();">Jump</a> | ||||
|     <a id="jump-next-diff" title="Jump to next difference">Jump</a> | ||||
| </div> | ||||
|  | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| @@ -79,8 +88,6 @@ | ||||
|     </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, highlight text to add to ignore filters</div> | ||||
|  | ||||
|          {% if password_enabled_and_share_is_off %} | ||||
|            <div class="tip">Pro-tip: You can enable <strong>"share access when password is enabled"</strong> from settings</div> | ||||
|          {% endif %} | ||||
| @@ -91,8 +98,8 @@ | ||||
|              <tbody> | ||||
|              <tr> | ||||
|                  <!-- just proof of concept copied straight from github.com/kpdecker/jsdiff --> | ||||
|                  <td id="a" style="display: none;">{{previous}}</td> | ||||
|                  <td id="b" style="display: none;">{{newest}}</td> | ||||
|                  <td id="a" style="display: none;">{{from_version_file_contents}}</td> | ||||
|                  <td id="b" style="display: none;">{{to_version_file_contents}}</td> | ||||
|                  <td id="diff-col"> | ||||
|                      <span id="result" class="highlightable-filter"></span> | ||||
|                  </td> | ||||
|   | ||||
| @@ -4,8 +4,10 @@ | ||||
| {% from '_common_fields.jinja' import render_common_settings_form %} | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script> | ||||
|  | ||||
|     const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}'); | ||||
|     const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}'); | ||||
|     const browser_steps_fetch_screenshot_image_url="{{url_for('browser_steps.browser_steps_fetch_screenshot_image', uuid=uuid)}}"; | ||||
|     const browser_steps_last_error_step={{ watch.browser_steps_last_error_step|tojson }}; | ||||
|     const browser_steps_start_url="{{url_for('browser_steps.browsersteps_start_session', uuid=uuid)}}"; | ||||
|     const browser_steps_sync_url="{{url_for('browser_steps.browsersteps_ui_update', uuid=uuid)}}"; | ||||
| {% if emailprefix %} | ||||
| @@ -49,6 +51,7 @@ | ||||
|             <li class="tab"><a href="#restock">Restock Detection</a></li> | ||||
|             {% endif %} | ||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||
|             <li class="tab"><a href="#stats">Stats</a></li> | ||||
|         </ul> | ||||
|     </div> | ||||
|  | ||||
| @@ -109,7 +112,7 @@ | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                             <p>Use the <strong>Basic</strong> method (default) where your watched site doesn't need Javascript to render.</p> | ||||
|                             <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> | ||||
|                             Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a> | ||||
|                             Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a> | ||||
|                         </span> | ||||
|                     </div> | ||||
|                 {% if form.proxy %} | ||||
| @@ -441,7 +444,35 @@ Unavailable") }} | ||||
|                 </fieldset> | ||||
|             </div> | ||||
|             {% endif %} | ||||
|  | ||||
|             <div class="tab-pane-inner" id="stats"> | ||||
|                 <div class="pure-control-group"> | ||||
|                     <style> | ||||
|                     #stats-table tr > td:first-child { | ||||
|                         font-weight: bold; | ||||
|                     } | ||||
|                     </style> | ||||
|                     <table class="pure-table" id="stats-table"> | ||||
|                         <tbody> | ||||
|                         <tr> | ||||
|                             <td>Check count</td> | ||||
|                             <td>{{ watch.check_count }}</td> | ||||
|                         </tr> | ||||
|                         <tr> | ||||
|                             <td>Consecutive filter failures</td> | ||||
|                             <td>{{ watch.consecutive_filter_failures }}</td> | ||||
|                         </tr> | ||||
|                         <tr> | ||||
|                             <td>History length</td> | ||||
|                             <td>{{ watch.history|length }}</td> | ||||
|                         </tr> | ||||
|                         <tr> | ||||
|                             <td>Last fetch time</td> | ||||
|                             <td>{{ watch.fetch_time }}s</td> | ||||
|                         </tr> | ||||
|                         </tbody> | ||||
|                     </table> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div id="actions"> | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_button(form.save_button) }} | ||||
|   | ||||
| @@ -109,7 +109,7 @@ | ||||
|                         <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> | ||||
|                     </span> | ||||
|                     <br> | ||||
|                     Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a> | ||||
|                     Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a> | ||||
|                 </div> | ||||
|                 <fieldset class="pure-group" id="webdriver-override-options"> | ||||
|                     <div class="pure-form-message-inline"> | ||||
|   | ||||
| @@ -154,8 +154,8 @@ | ||||
|                     {% endfor %} | ||||
|  | ||||
|                 </td> | ||||
|                 <td class="last-checked">{{watch|format_last_checked_time|safe}}</td> | ||||
|                 <td class="last-changed">{% if watch.history_n >=2 and watch.last_changed >0 %} | ||||
|                 <td class="last-checked" data-timestamp="{{ watch.last_checked }}">{{watch|format_last_checked_time|safe}}</td> | ||||
|                 <td class="last-changed" data-timestamp="{{ watch.last_changed }}">{% if watch.history_n >=2 and watch.last_changed >0 %} | ||||
|                     {{watch.last_changed|format_timestamp_timeago}} | ||||
|                     {% else %} | ||||
|                     Not yet | ||||
| @@ -178,13 +178,18 @@ | ||||
|             </tbody> | ||||
|         </table> | ||||
|         <ul id="post-list-buttons"> | ||||
|             {% if errored_count %} | ||||
|             <li> | ||||
|                 <a href="{{url_for('index', with_errors=1, tag=request.args.get('tag')) }}" class="pure-button button-tag button-error ">With errors ({{ errored_count }})</a> | ||||
|             </li> | ||||
|             {% endif %} | ||||
|             {% if has_unviewed %} | ||||
|             <li> | ||||
|                 <a href="{{url_for('mark_all_viewed', tag=request.args.get('tag')) }}" class="pure-button button-tag ">Mark all viewed</a> | ||||
|                 <a href="{{url_for('mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag ">Mark all viewed</a> | ||||
|             </li> | ||||
|             {% endif %} | ||||
|             <li> | ||||
|                <a href="{{ url_for('form_watch_checknow', tag=active_tag) }}" class="pure-button button-tag ">Recheck | ||||
|                <a href="{{ url_for('form_watch_checknow', tag=active_tag, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag ">Recheck | ||||
|                 all {% if active_tag%} in "{{tags[active_tag].title}}"{%endif%}</a> | ||||
|             </li> | ||||
|             <li> | ||||
|   | ||||
| @@ -89,7 +89,7 @@ def test_check_basic_change_detection_functionality(client, live_server): | ||||
|  | ||||
|     # Following the 'diff' link, it should no longer display as 'unviewed' even after we recheck it a few times | ||||
|     res = client.get(url_for("diff_history_page", uuid="first")) | ||||
|     assert b'Compare newest' in res.data | ||||
|     assert b'selected=""' in res.data, "Confirm diff history page loaded" | ||||
|  | ||||
|     # Check the [preview] pulls the right one | ||||
|     res = client.get( | ||||
|   | ||||
| @@ -202,3 +202,35 @@ def test_check_filter_and_regex_extract(client, live_server): | ||||
|  | ||||
|     # Should not be here | ||||
|     assert b'Some text that did change' not in res.data | ||||
|  | ||||
|  | ||||
|  | ||||
| def test_regex_error_handling(client, live_server): | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     ### test regex error handling | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"extract_text": '/something bad\d{3/XYZ', | ||||
|               "url": test_url, | ||||
|               "fetch_backend": "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     with open('/tmp/fuck.html', 'wb') as f: | ||||
|         f.write(res.data) | ||||
|  | ||||
|     assert b'is not a valid regular expression.' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -2,12 +2,61 @@ | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI | ||||
| from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \ | ||||
|     extract_UUID_from_client | ||||
|  | ||||
|  | ||||
| def set_original_cdata_xml(): | ||||
|     test_return_data = """<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"> | ||||
|     <channel> | ||||
|     <title>Gizi</title> | ||||
|     <link>https://test.com</link> | ||||
|     <atom:link href="https://testsite.com" rel="self" type="application/rss+xml"/> | ||||
|     <description> | ||||
|     <![CDATA[ The Future Could Be Here ]]> | ||||
|     </description> | ||||
|     <language>en</language> | ||||
|     <item> | ||||
|     <title> | ||||
|     <![CDATA[ <img src="https://testsite.com/hacked.jpg"> Hackers can access your computer ]]> | ||||
|     </title> | ||||
|     <link>https://testsite.com/news/12341234234</link> | ||||
|     <description> | ||||
|     <![CDATA[ <img class="type:primaryImage" src="https://testsite.com/701c981da04869e.jpg"/><p>The days of Terminator and The Matrix could be closer. But be positive.</p><p><a href="https://testsite.com">Read more link...</a></p> ]]> | ||||
|     </description> | ||||
|     <category>cybernetics</category> | ||||
|     <category>rand corporation</category> | ||||
|     <pubDate>Tue, 17 Oct 2023 15:10:00 GMT</pubDate> | ||||
|     <guid isPermaLink="false">1850933241</guid> | ||||
|     <dc:creator> | ||||
|     <![CDATA[ Mr Hacker News ]]> | ||||
|     </dc:creator> | ||||
|     <media:thumbnail url="https://testsite.com/thumbnail-c224e10d81488e818701c981da04869e.jpg"/> | ||||
|     </item> | ||||
|  | ||||
|     <item> | ||||
|         <title>    Some other title    </title> | ||||
|         <link>https://testsite.com/news/12341234236</link> | ||||
|         <description> | ||||
|         Some other description | ||||
|         </description> | ||||
|     </item>     | ||||
|     </channel> | ||||
|     </rss> | ||||
|             """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|  | ||||
| def test_setup(client, live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def test_rss_and_token(client, live_server): | ||||
|     #    live_server_setup(live_server) | ||||
|  | ||||
|     set_original_response() | ||||
|     live_server_setup(live_server) | ||||
|     rss_token = extract_rss_token_from_UI(client) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
| @@ -17,11 +66,11 @@ def test_rss_and_token(client, live_server): | ||||
|     ) | ||||
|  | ||||
|     assert b"1 Imported" in res.data | ||||
|     rss_token = extract_rss_token_from_UI(client) | ||||
|  | ||||
|     time.sleep(2) | ||||
|     wait_for_all_checks(client) | ||||
|     set_modified_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(2) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.get( | ||||
| @@ -37,3 +86,80 @@ def test_rss_and_token(client, live_server): | ||||
|     ) | ||||
|     assert b"Access denied, bad token" not in res.data | ||||
|     assert b"Random content" in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|  | ||||
| def test_basic_cdata_rss_markup(client, live_server): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     set_original_cdata_xml() | ||||
|  | ||||
|     test_url = url_for('test_endpoint', content_type="application/xml", _external=True) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b'CDATA' not in res.data | ||||
|     assert b'<![' not in res.data | ||||
|     assert b'Hackers can access your computer' in res.data | ||||
|     assert b'The days of Terminator' in res.data | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|  | ||||
| def test_rss_xpath_filtering(client, live_server): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     set_original_cdata_xml() | ||||
|  | ||||
|     test_url = url_for('test_endpoint', content_type="application/xml", _external=True) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|  | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid, unpause_on_save=1), | ||||
|         data={ | ||||
|                 "include_filters": "//item/title", | ||||
|                 "fetch_backend": "html_requests", | ||||
|                 "headers": "", | ||||
|                 "proxy": "no-proxy", | ||||
|                 "tags": "", | ||||
|                 "url": test_url, | ||||
|               }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"unpaused" in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b'CDATA' not in res.data | ||||
|     assert b'<![' not in res.data | ||||
|     # #1874  All but the first <title was getting selected | ||||
|     # Convert any HTML with just a top level <title> to <h1> to be sure title renders | ||||
|  | ||||
|     assert b'Hackers can access your computer' in res.data # Should ONLY be selected by the xpath | ||||
|     assert b'Some other title' in res.data  # Should ONLY be selected by the xpath | ||||
|     assert b'The days of Terminator' not in res.data # Should NOT be selected by the xpath | ||||
|     assert b'Some other description' not in res.data  # Should NOT be selected by the xpath | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
| from ..html_tools import * | ||||
|  | ||||
| @@ -86,14 +86,14 @@ def test_check_xpath_filter_utf8(client, live_server): | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     time.sleep(1) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"include_filters": filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     time.sleep(3) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'Unicode strings with encoding declaration are not supported.' not in res.data | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
| @@ -140,14 +140,14 @@ def test_check_xpath_text_function_utf8(client, live_server): | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     time.sleep(1) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"include_filters": filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     time.sleep(3) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'Unicode strings with encoding declaration are not supported.' not in res.data | ||||
|  | ||||
| @@ -164,7 +164,6 @@ def test_check_xpath_text_function_utf8(client, live_server): | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_check_markup_xpath_filter_restriction(client, live_server): | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|     xpath_filter = "//*[contains(@class, 'sametext')]" | ||||
|  | ||||
| @@ -183,7 +182,7 @@ def test_check_markup_xpath_filter_restriction(client, live_server): | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
| @@ -195,7 +194,7 @@ def test_check_markup_xpath_filter_restriction(client, live_server): | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # view it/reset state back to viewed | ||||
|     client.get(url_for("diff_history_page", uuid="first"), follow_redirects=True) | ||||
| @@ -206,7 +205,7 @@ def test_check_markup_xpath_filter_restriction(client, live_server): | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
| @@ -216,9 +215,6 @@ def test_check_markup_xpath_filter_restriction(client, live_server): | ||||
|  | ||||
| def test_xpath_validation(client, live_server): | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
| @@ -227,7 +223,7 @@ def test_xpath_validation(client, live_server): | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     time.sleep(2) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
| @@ -244,11 +240,8 @@ def test_check_with_prefix_include_filters(client, live_server): | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     set_original_response() | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
| @@ -257,7 +250,7 @@ def test_check_with_prefix_include_filters(client, live_server): | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     time.sleep(3) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
| @@ -266,7 +259,7 @@ def test_check_with_prefix_include_filters(client, live_server): | ||||
|     ) | ||||
|  | ||||
|     assert b"Updated watch." in res.data | ||||
|     time.sleep(3) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
| @@ -277,3 +270,46 @@ def test_check_with_prefix_include_filters(client, live_server): | ||||
|     assert b"Some text that will change" not in res.data #not in selector | ||||
|  | ||||
|     client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
| def test_various_rules(client, live_server): | ||||
|     # Just check these don't error | ||||
|     #live_server_setup(live_server) | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write("""<html> | ||||
|        <body> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div class="sametext">Some text thats the same</div> | ||||
|      <div class="changetext">Some text that will change</div> | ||||
|      <a href=''>some linky </a> | ||||
|      <a href=''>another some linky </a> | ||||
|      <!-- related to https://github.com/dgtlmoon/changedetection.io/pull/1774 --> | ||||
|      <input   type="email"   id="email" /> | ||||
|      </body> | ||||
|      </html> | ||||
|     """) | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|  | ||||
|     for r in ['//div', '//a', 'xpath://div', 'xpath://a']: | ||||
|         res = client.post( | ||||
|             url_for("edit_page", uuid="first"), | ||||
|             data={"include_filters": r, | ||||
|                   "url": test_url, | ||||
|                   "tags": "", | ||||
|                   "headers": "", | ||||
|                   'fetch_backend': "html_requests"}, | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|         wait_for_all_checks(client) | ||||
|         assert b"Updated watch." in res.data | ||||
|         res = client.get(url_for("index")) | ||||
|         assert b'fetch-error' not in res.data, f"Should not see errors after '{r} filter" | ||||
|   | ||||
| @@ -1,18 +1,19 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import time | ||||
| import os | ||||
| from flask import url_for | ||||
| from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
|  | ||||
| def test_setup(client, live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| # 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 | ||||
|     import json | ||||
|  | ||||
|     assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||
|     time.sleep(1) | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|  | ||||
|     # Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url | ||||
|     test_url = "https://changedetection.io/ci-test/test-runjs.html" | ||||
| @@ -60,4 +61,67 @@ def test_visual_selector_content_ready(client, live_server): | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b'notification_screenshot' in res.data | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| def test_basic_browserstep(client, live_server): | ||||
|  | ||||
|     assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     # Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url | ||||
|     test_url = "https://changedetection.io/ci-test/test-runjs.html" | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": '', '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={ | ||||
|               "url": test_url, | ||||
|               "tags": "", | ||||
|               "headers": "", | ||||
|               'fetch_backend': "html_webdriver", | ||||
|               'browser_steps-0-operation': 'Goto site', | ||||
|               'browser_steps-1-operation': 'Click element', | ||||
|               'browser_steps-1-selector': 'button[name=test-button]', | ||||
|               'browser_steps-1-value': '' | ||||
|         }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"unpaused" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|  | ||||
|  | ||||
|     # now test for 404 errors | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid, unpause_on_save=1), | ||||
|         data={ | ||||
|               "url": "https://changedetection.io/404", | ||||
|               "tags": "", | ||||
|               "headers": "", | ||||
|               'fetch_backend': "html_webdriver", | ||||
|               'browser_steps-0-operation': 'Goto site', | ||||
|               'browser_steps-1-operation': 'Click element', | ||||
|               'browser_steps-1-selector': 'button[name=test-button]', | ||||
|               'browser_steps-1-value': '' | ||||
|         }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"unpaused" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'Error - 404' in res.data | ||||
|  | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -238,7 +238,9 @@ class update_worker(threading.Thread): | ||||
|                             # Used as a default and also by some tests | ||||
|                             update_handler = text_json_diff.perform_site_check(datastore=self.datastore) | ||||
|  | ||||
|                         self.datastore.data['watching'][uuid]['browser_steps_last_error_step'] = None | ||||
|                         changed_detected, update_obj, contents = update_handler.run(uuid, skip_when_checksum_same=queued_item_data.item.get('skip_when_checksum_same')) | ||||
|  | ||||
|                         # 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 | ||||
| @@ -324,8 +326,13 @@ class update_worker(threading.Thread): | ||||
|                         if not self.datastore.data['watching'].get(uuid): | ||||
|                             continue | ||||
|  | ||||
|                         err_text = "Warning, browser step at position {} could not run, target not found, check the watch, add a delay if necessary.".format(e.step_n+1) | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text}) | ||||
|                         error_step = e.step_n + 1 | ||||
|                         err_text = f"Warning, browser step at position {error_step} could not run, target not found, check the watch, add a delay if necessary, view Browser Steps to see screenshot at that step" | ||||
|                         self.datastore.update_watch(uuid=uuid, | ||||
|                                                     update_obj={'last_error': err_text, | ||||
|                                                                 'browser_steps_last_error_step': error_step | ||||
|                                                                 } | ||||
|                                                     ) | ||||
|  | ||||
|  | ||||
|                         if self.datastore.data['watching'][uuid].get('filter_failure_notification_send', False): | ||||
|   | ||||
| @@ -81,7 +81,7 @@ services: | ||||
| #        restart: unless-stopped | ||||
|  | ||||
|      # Used for fetching pages via Playwright+Chrome where you need Javascript support. | ||||
|  | ||||
|      # Note: Playwright/browserless not supported on ARM type devices (rPi etc) | ||||
| #    playwright-chrome: | ||||
| #        hostname: playwright-chrome | ||||
| #        image: browserless/chrome | ||||
|   | ||||
| @@ -33,7 +33,7 @@ dnspython<2.3.0 | ||||
| # jq not available on Windows so must be installed manually | ||||
|  | ||||
| # Notification library | ||||
| apprise~=1.5.0 | ||||
| apprise~=1.6.0 | ||||
|  | ||||
| # apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 | ||||
| paho-mqtt | ||||
| @@ -49,8 +49,7 @@ beautifulsoup4 | ||||
| # XPath filtering, lxml is required by bs4 anyway, but put it here to be safe. | ||||
| lxml | ||||
|  | ||||
| # 3.141 was missing socksVersion, 3.150 was not in pypi, so we try 4.1.0 | ||||
| selenium~=4.1.0 | ||||
| selenium~=4.14.0 | ||||
|  | ||||
| # https://stackoverflow.com/questions/71652965/importerror-cannot-import-name-safe-str-cmp-from-werkzeug-security/71653849#71653849 | ||||
| # ImportError: cannot import name 'safe_str_cmp' from 'werkzeug.security' | ||||
| @@ -63,7 +62,8 @@ jinja2-time | ||||
|  | ||||
| # https://peps.python.org/pep-0508/#environment-markers | ||||
| # https://github.com/dgtlmoon/changedetection.io/pull/1009 | ||||
| jq~=1.3 ;python_version >= "3.8" and sys_platform == "linux" | ||||
| jq~=1.3; python_version >= "3.8" and sys_platform == "darwin" | ||||
| jq~=1.3; python_version >= "3.8" and sys_platform == "linux" | ||||
|  | ||||
| # Any current modern version, required so far for screenshot PNG->JPEG conversion but will be used more in the future | ||||
| pillow | ||||
|   | ||||
		Reference in New Issue
	
	Block a user