mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 14:47:21 +00:00 
			
		
		
		
	Compare commits
	
		
			53 Commits
		
	
	
		
			1450-featu
			...
			browserste
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 24baf44faa | ||
|   | d0e5c457e0 | ||
|   | 266c6ba1c0 | ||
|   | a46bbc8300 | ||
|   | d865ea0103 | ||
|   | 5f338d7824 | ||
|   | 0b563a93ec | ||
|   | d939882dde | ||
|   | 690cf4acc9 | ||
|   | 3cb3c7ba2e | ||
|   | 5325918f29 | ||
|   | 8eee913438 | ||
|   | 06921d973e | ||
|   | 316f28a0f2 | ||
|   | 3801d339f5 | ||
|   | d814535dc6 | ||
|   | cf3f3e4497 | ||
|   | ba76c2a280 | ||
|   | 94f38f052e | ||
|   | 1710885fc4 | ||
|   | 2018e73240 | ||
|   | fae8c89a4e | ||
|   | 40988c55c6 | ||
|   | 5aa713b7ea | ||
|   | e1f5dfb703 | ||
|   | 966600d28e | ||
|   | e7ac356d99 | ||
|   | e874df4ffc | ||
|   | d1f44d0345 | ||
|   | 8536af0845 | ||
|   | 9076ba6bd3 | ||
|   | 43af18e2bc | ||
|   | ad75e8cdd0 | ||
|   | f604643356 | ||
|   | d5fd22f693 | ||
|   | 1d9d11b3f5 | ||
|   | f49464f451 | ||
|   | bc6bde4062 | ||
|   | 2863167f45 | ||
|   | ce3966c104 | ||
|   | d5f574ca17 | ||
|   | c96ece170a | ||
|   | 1fb90bbddc | ||
|   | 55b6ae86e8 | ||
|   | 66b892f770 | ||
|   | 3b80bb2f0e | ||
|   | e6d2d87b31 | ||
|   | 6e71088cde | ||
|   | 2bc988dffc | ||
|   | a578de36c5 | ||
|   | 4c74d39df0 | ||
|   | c454cbb808 | ||
|   | 6f1eec0d5a | 
							
								
								
									
										6
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							| @@ -98,7 +98,8 @@ jobs: | ||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 | ||||
|           cache-from: type=local,src=/tmp/.buildx-cache | ||||
|           cache-to: type=local,dest=/tmp/.buildx-cache | ||||
|           provenance: false | ||||
| # Looks like this was disabled | ||||
| #          provenance: false | ||||
|  | ||||
|       # A new tagged release is required, which builds :tag and :latest | ||||
|       - name: Build and push :tag | ||||
| @@ -117,7 +118,8 @@ jobs: | ||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 | ||||
|           cache-from: type=local,src=/tmp/.buildx-cache | ||||
|           cache-to: type=local,dest=/tmp/.buildx-cache | ||||
|           provenance: false | ||||
| # Looks like this was disabled | ||||
| #          provenance: false | ||||
|  | ||||
|       - name: Image digest | ||||
|         run: echo step SHA ${{ steps.vars.outputs.sha_short }} tag ${{steps.vars.outputs.tag}} branch ${{steps.vars.outputs.branch}} digest ${{ steps.docker_build.outputs.digest }} | ||||
|   | ||||
							
								
								
									
										38
									
								
								.github/workflows/pypi.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										38
									
								
								.github/workflows/pypi.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,38 +0,0 @@ | ||||
| name: PyPi Test and Push tagged release | ||||
|  | ||||
| # Triggers the workflow on push or pull request events | ||||
| on: | ||||
|   workflow_run: | ||||
|     workflows: ["ChangeDetection.io Test"] | ||||
|     tags: '*.*' | ||||
|     types: [completed] | ||||
|  | ||||
|  | ||||
| jobs: | ||||
|   test-build: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|  | ||||
|       - uses: actions/checkout@v2 | ||||
|       - name: Set up Python 3.9 | ||||
|         uses: actions/setup-python@v2 | ||||
|         with: | ||||
|           python-version: 3.9 | ||||
|  | ||||
|  | ||||
|       - name: Test that pip builds without error | ||||
|         run: | | ||||
|           pip3 --version | ||||
|           python3 -m pip install wheel | ||||
|           python3 setup.py bdist_wheel | ||||
|           python3 -m pip install dist/changedetection.io-*-none-any.whl --force | ||||
|           changedetection.io -d /tmp -p 10000 & | ||||
|           sleep 3 | ||||
|           curl http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null | ||||
|           killall -9 changedetection.io | ||||
|  | ||||
|       # https://github.com/docker/build-push-action/blob/master/docs/advanced/test-before-push.md ? | ||||
|       # https://github.com/docker/buildx/issues/59 ? Needs to be one platform? | ||||
|  | ||||
|       # https://github.com/docker/buildx/issues/495#issuecomment-918925854 | ||||
| #if: ${{ github.event_name == 'release'}} | ||||
							
								
								
									
										14
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							| @@ -30,7 +30,7 @@ jobs: | ||||
|  | ||||
|           # 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 "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 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 | ||||
|  | ||||
|       - name: Build changedetection.io container for testing | ||||
|         run: |          | ||||
| @@ -50,10 +50,18 @@ jobs: | ||||
|         run: | | ||||
|            | ||||
|           # Selenium fetch | ||||
|           docker run -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network  test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py' | ||||
|           docker run --rm -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py' | ||||
|            | ||||
|           # Playwright/Browserless fetch | ||||
|           docker run -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network  test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py && pytest tests/visualselector/test_fetch_data.py' | ||||
|           docker run --rm -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py && pytest tests/visualselector/test_fetch_data.py' | ||||
|            | ||||
|           # restock detection via playwright - added name=changedet here so that playwright/browserless can connect to it | ||||
|           docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py' | ||||
|  | ||||
|       - name: Test with puppeteer fetcher and disk cache | ||||
|         run: | | ||||
|           docker run --rm -e "PUPPETEER_DISK_CACHE=/tmp/data/" -e "USE_EXPERIMENTAL_PUPPETEER_FETCH=yes" -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py && pytest tests/visualselector/test_fetch_data.py' | ||||
|           # Browserless would have had -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" added above | ||||
|  | ||||
|       - name: Test proxy interaction | ||||
|         run: | | ||||
|   | ||||
							
								
								
									
										36
									
								
								.github/workflows/test-pip-build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								.github/workflows/test-pip-build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| name: ChangeDetection.io PIP package test | ||||
|  | ||||
| # Triggers the workflow on push or pull request events | ||||
|  | ||||
| # This line doesnt work, even tho it is the documented one | ||||
| on: [push, pull_request] | ||||
|  | ||||
|   # Changes to requirements.txt packages and Dockerfile may or may not always be compatible with arm etc, so worth testing | ||||
|   # @todo: some kind of path filter for requirements.txt and Dockerfile | ||||
| jobs: | ||||
|   test-pip-build-basics: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|         - uses: actions/checkout@v2 | ||||
|  | ||||
|         - name: Set up Python 3.9 | ||||
|           uses: actions/setup-python@v2 | ||||
|           with: | ||||
|             python-version: 3.9 | ||||
|  | ||||
|  | ||||
|         - name: Test that the basic pip built package runs without error | ||||
|           run: | | ||||
|             set -e | ||||
|             mkdir dist | ||||
|             pip3 install wheel | ||||
|             python3 setup.py bdist_wheel             | ||||
|             pip3 install -r requirements.txt | ||||
|             rm ./changedetection.py | ||||
|             rm -rf changedetectio | ||||
|              | ||||
|             pip3 install dist/changedetection.io*.whl | ||||
|             changedetection.io -d /tmp -p 10000 & | ||||
|             sleep 3 | ||||
|             curl http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null | ||||
|             killall -9 changedetection.io | ||||
| @@ -1,6 +1,7 @@ | ||||
| recursive-include changedetectionio/api * | ||||
| recursive-include changedetectionio/blueprint * | ||||
| recursive-include changedetectionio/model * | ||||
| recursive-include changedetectionio/processors * | ||||
| recursive-include changedetectionio/res * | ||||
| recursive-include changedetectionio/static * | ||||
| recursive-include changedetectionio/templates * | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| ## Web Site Change Detection, Monitoring and Notification. | ||||
| ## Web Site Change Detection, Restock monitoring and notifications. | ||||
|  | ||||
| **_Detect website content changes and perform meaningful actions - trigger notifications via Discord, Email, Slack, Telegram, API calls and many more._** | ||||
|  | ||||
| @@ -65,6 +65,7 @@ Requires Playwright to be enabled. | ||||
| - Get notified when certain keywords appear in Twitter search results | ||||
| - Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords. | ||||
| - Get alerts when new job positions are open on Bamboo HR and other job platforms | ||||
| - Website defacement monitoring | ||||
|  | ||||
| _Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_ | ||||
|  | ||||
| @@ -102,6 +103,8 @@ $ docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/d | ||||
|  | ||||
| `:latest` tag is our latest stable release, `:dev` tag is our bleeding edge `master` branch. | ||||
|  | ||||
| Alternative docker repository over at ghcr - [ghcr.io/dgtlmoon/changedetection.io](https://ghcr.io/dgtlmoon/changedetection.io) | ||||
|  | ||||
| ### Windows | ||||
|  | ||||
| See the install instructions at the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Microsoft-Windows | ||||
|   | ||||
| @@ -33,10 +33,12 @@ from flask import ( | ||||
|     url_for, | ||||
| ) | ||||
|  | ||||
| from flask_paginate import Pagination, get_page_parameter | ||||
|  | ||||
| from changedetectionio import html_tools | ||||
| from changedetectionio.api import api_v1 | ||||
|  | ||||
| __version__ = '0.40.3' | ||||
| __version__ = '0.41.1' | ||||
|  | ||||
| datastore = None | ||||
|  | ||||
| @@ -64,6 +66,9 @@ app.config.exit = Event() | ||||
|  | ||||
| app.config['NEW_VERSION_AVAILABLE'] = False | ||||
|  | ||||
| if os.getenv('FLASK_SERVER_NAME'): | ||||
|     app.config['SERVER_NAME'] = os.getenv('FLASK_SERVER_NAME') | ||||
|  | ||||
| #app.config["EXPLAIN_TEMPLATE_LOADING"] = True | ||||
|  | ||||
| # Disables caching of the templates | ||||
| @@ -337,8 +342,6 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             if len(dates) < 2: | ||||
|                 continue | ||||
|  | ||||
|             prev_fname = watch.history[dates[-2]] | ||||
|  | ||||
|             if not watch.viewed: | ||||
|                 # Re #239 - GUID needs to be individual for each event | ||||
|                 # @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228) | ||||
| @@ -359,9 +362,12 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|                 watch_title = watch.get('title') if watch.get('title') else watch.get('url') | ||||
|                 fe.title(title=watch_title) | ||||
|                 latest_fname = watch.history[dates[-1]] | ||||
|  | ||||
|                 html_diff = diff.render_diff(prev_fname, latest_fname, include_equal=False, line_feed_sep="</br>") | ||||
|                 html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]), | ||||
|                                              newest_version_file_contents=watch.get_history_snapshot(dates[-1]), | ||||
|                                              include_equal=False, | ||||
|                                              line_feed_sep="<br>") | ||||
|  | ||||
|                 fe.content(content="<html><body><h4>{}</h4>{}</body></html>".format(watch_title, html_diff), | ||||
|                            type='CDATA') | ||||
|  | ||||
| @@ -415,6 +421,10 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         existing_tags = datastore.get_all_tags() | ||||
|         form = forms.quickWatchForm(request.form) | ||||
|         page = request.args.get(get_page_parameter(), type=int, default=1) | ||||
|         total_count = len(sorted_watches) if sorted_watches else len(datastore.data['watching']) | ||||
|         pagination = Pagination(page=page, total=total_count, per_page=int(os.getenv('pagination_per_page', 50)), css_framework = "semantic") | ||||
|  | ||||
|         output = render_template( | ||||
|             "watch-overview.html", | ||||
|                                  # Don't link to hosting when we're on the hosting environment | ||||
| @@ -425,16 +435,28 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                                  has_proxies=datastore.proxy_list, | ||||
|                                  has_unviewed=datastore.has_unviewed, | ||||
|                                  hosted_sticky=os.getenv("SALTED_PASS", False) == False, | ||||
|                                  pagination=pagination, | ||||
|                                  queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue], | ||||
|                                  sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'), | ||||
|                                  sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'), | ||||
|                                  system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'), | ||||
|                                  tags=existing_tags, | ||||
|                                  watches=sorted_watches | ||||
|                                  ) | ||||
|  | ||||
|  | ||||
|         if session.get('share-link'): | ||||
|             del(session['share-link']) | ||||
|         return output | ||||
|  | ||||
|         resp = make_response(output) | ||||
|  | ||||
|         # The template can run on cookie or url query info | ||||
|         if request.args.get('sort'): | ||||
|             resp.set_cookie('sort', request.args.get('sort')) | ||||
|         if request.args.get('order'): | ||||
|             resp.set_cookie('order', request.args.get('order')) | ||||
|  | ||||
|         return resp | ||||
|  | ||||
|  | ||||
|  | ||||
|     # AJAX endpoint for sending a test | ||||
| @@ -459,11 +481,19 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         try: | ||||
|             n_object = {'watch_url': request.form['window_url'], | ||||
|                         'notification_urls': request.form['notification_urls'].splitlines(), | ||||
|                         'notification_title': request.form['notification_title'].strip(), | ||||
|                         'notification_body': request.form['notification_body'].strip(), | ||||
|                         'notification_format': request.form['notification_format'].strip() | ||||
|                         'notification_urls': request.form['notification_urls'].splitlines() | ||||
|                         } | ||||
|  | ||||
|             # Only use if present, if not set in n_object it should use the default system value | ||||
|             if 'notification_format' in request.form and request.form['notification_format'].strip(): | ||||
|                 n_object['notification_format'] = request.form.get('notification_format', '').strip() | ||||
|  | ||||
|             if 'notification_title' in request.form and request.form['notification_title'].strip(): | ||||
|                 n_object['notification_title'] = request.form.get('notification_title', '').strip() | ||||
|  | ||||
|             if 'notification_body' in request.form and request.form['notification_body'].strip(): | ||||
|                 n_object['notification_body'] = request.form.get('notification_body', '').strip() | ||||
|  | ||||
|             notification_q.put(n_object) | ||||
|         except Exception as e: | ||||
|             return make_response({'error': str(e)}, 400) | ||||
| @@ -511,8 +541,9 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|     # https://wtforms.readthedocs.io/en/3.0.x/forms/#wtforms.form.Form.populate_obj ? | ||||
|  | ||||
|     def edit_page(uuid): | ||||
|         from changedetectionio import forms | ||||
|         from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config | ||||
|         from . import forms | ||||
|         from .blueprint.browser_steps.browser_steps import browser_step_ui_config | ||||
|         from . import processors | ||||
|  | ||||
|         using_default_check_time = True | ||||
|         # More for testing, possible to return the first/only | ||||
| @@ -527,6 +558,15 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             flash("No watch with the UUID %s found." % (uuid), "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         switch_processor = request.args.get('switch_processor') | ||||
|         if switch_processor: | ||||
|             for p in processors.available_processors(): | ||||
|                 if p[0] == switch_processor: | ||||
|                     datastore.data['watching'][uuid]['processor'] = switch_processor | ||||
|                     flash(f"Switched to mode - {p[1]}.") | ||||
|                     datastore.clear_watch_history(uuid) | ||||
|                     redirect(url_for('edit_page', uuid=uuid)) | ||||
|  | ||||
|         # be sure we update with a copy instead of accidently editing the live object by reference | ||||
|         default = deepcopy(datastore.data['watching'][uuid]) | ||||
|  | ||||
| @@ -587,6 +627,16 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             if datastore.proxy_list is not None and form.data['proxy'] == '': | ||||
|                 extra_update_obj['proxy'] = None | ||||
|  | ||||
|             # Unsetting all filter_text methods should make it go back to default | ||||
|             # This particularly affects tests running | ||||
|             if 'filter_text_added' in form.data and not form.data.get('filter_text_added') \ | ||||
|                     and 'filter_text_replaced' in form.data and not form.data.get('filter_text_replaced') \ | ||||
|                     and 'filter_text_removed' in form.data and not form.data.get('filter_text_removed'): | ||||
|                 extra_update_obj['filter_text_added'] = True | ||||
|                 extra_update_obj['filter_text_replaced'] = True | ||||
|                 extra_update_obj['filter_text_removed'] = True | ||||
|  | ||||
|  | ||||
|             datastore.data['watching'][uuid].update(form.data) | ||||
|             datastore.data['watching'][uuid].update(extra_update_obj) | ||||
|  | ||||
| @@ -633,6 +683,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             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, | ||||
|                                      current_base_url=datastore.data['settings']['application']['base_url'], | ||||
|                                      emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||
| @@ -735,6 +786,8 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|     @login_optionally_required | ||||
|     def import_page(): | ||||
|         remaining_urls = [] | ||||
|         from . import forms | ||||
|  | ||||
|         if request.method == 'POST': | ||||
|             from .importer import import_url_list, import_distill_io_json | ||||
|  | ||||
| @@ -742,7 +795,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             if request.values.get('urls') and len(request.values.get('urls').strip()): | ||||
|                 # Import and push into the queue for immediate update check | ||||
|                 importer = import_url_list() | ||||
|                 importer.run(data=request.values.get('urls'), flash=flash, datastore=datastore) | ||||
|                 importer.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor')) | ||||
|                 for uuid in importer.new_uuids: | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True})) | ||||
|  | ||||
| @@ -760,9 +813,12 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True})) | ||||
|  | ||||
|  | ||||
|  | ||||
|         form = forms.importForm(formdata=request.form if request.method == 'POST' else None, | ||||
| #                               data=default, | ||||
|                                ) | ||||
|         # Could be some remaining, or we could be on GET | ||||
|         output = render_template("import.html", | ||||
|                                  form=form, | ||||
|                                  import_url_list_remaining="\n".join(remaining_urls), | ||||
|                                  original_distill_json='' | ||||
|                                  ) | ||||
| @@ -828,28 +884,22 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         # Save the current newest history as the most recently viewed | ||||
|         datastore.set_last_viewed(uuid, time.time()) | ||||
|  | ||||
|         newest_file = history[dates[-1]] | ||||
|  | ||||
|         # 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: | ||||
|             with open(newest_file, 'r', encoding='utf-8', errors='ignore') as f: | ||||
|                 newest_version_file_contents = f.read() | ||||
|             newest_version_file_contents = watch.get_history_snapshot(dates[-1]) | ||||
|         except Exception as e: | ||||
|             newest_version_file_contents = "Unable to read {}.\n".format(newest_file) | ||||
|             newest_version_file_contents = "Unable to read {}.\n".format(dates[-1]) | ||||
|  | ||||
|         previous_version = request.args.get('previous_version') | ||||
|         try: | ||||
|             previous_file = history[previous_version] | ||||
|         except KeyError: | ||||
|             # Not present, use a default value, the second one in the sorted list. | ||||
|             previous_file = history[dates[-2]] | ||||
|         previous_timestamp = dates[-2] | ||||
|         if previous_version: | ||||
|             previous_timestamp = previous_version | ||||
|  | ||||
|         try: | ||||
|             with open(previous_file, 'r', encoding='utf-8', errors='ignore') as f: | ||||
|                 previous_version_file_contents = f.read() | ||||
|             previous_version_file_contents = watch.get_history_snapshot(previous_timestamp) | ||||
|         except Exception as e: | ||||
|             previous_version_file_contents = "Unable to read {}.\n".format(previous_file) | ||||
|             previous_version_file_contents = "Unable to read {}.\n".format(previous_timestamp) | ||||
|  | ||||
|  | ||||
|         screenshot_url = watch.get_screenshot() | ||||
| @@ -929,37 +979,35 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             return output | ||||
|  | ||||
|         timestamp = list(watch.history.keys())[-1] | ||||
|         filename = watch.history[timestamp] | ||||
|         try: | ||||
|             with open(filename, 'r', encoding='utf-8', errors='ignore') as f: | ||||
|                 tmp = f.readlines() | ||||
|             tmp = watch.get_history_snapshot(timestamp).splitlines() | ||||
|  | ||||
|                 # Get what needs to be highlighted | ||||
|                 ignore_rules = watch.get('ignore_text', []) + datastore.data['settings']['application']['global_ignore_text'] | ||||
|             # Get what needs to be highlighted | ||||
|             ignore_rules = watch.get('ignore_text', []) + datastore.data['settings']['application']['global_ignore_text'] | ||||
|  | ||||
|                 # .readlines will keep the \n, but we will parse it here again, in the future tidy this up | ||||
|                 ignored_line_numbers = html_tools.strip_ignore_text(content="".join(tmp), | ||||
|                                                                     wordlist=ignore_rules, | ||||
|                                                                     mode='line numbers' | ||||
|                                                                     ) | ||||
|             # .readlines will keep the \n, but we will parse it here again, in the future tidy this up | ||||
|             ignored_line_numbers = html_tools.strip_ignore_text(content="\n".join(tmp), | ||||
|                                                                 wordlist=ignore_rules, | ||||
|                                                                 mode='line numbers' | ||||
|                                                                 ) | ||||
|  | ||||
|                 trigger_line_numbers = html_tools.strip_ignore_text(content="".join(tmp), | ||||
|                                                                     wordlist=watch['trigger_text'], | ||||
|                                                                     mode='line numbers' | ||||
|                                                                     ) | ||||
|                 # Prepare the classes and lines used in the template | ||||
|                 i=0 | ||||
|                 for l in tmp: | ||||
|                     classes=[] | ||||
|                     i+=1 | ||||
|                     if i in ignored_line_numbers: | ||||
|                         classes.append('ignored') | ||||
|                     if i in trigger_line_numbers: | ||||
|                         classes.append('triggered') | ||||
|                     content.append({'line': l, 'classes': ' '.join(classes)}) | ||||
|             trigger_line_numbers = html_tools.strip_ignore_text(content="\n".join(tmp), | ||||
|                                                                 wordlist=watch['trigger_text'], | ||||
|                                                                 mode='line numbers' | ||||
|                                                                 ) | ||||
|             # Prepare the classes and lines used in the template | ||||
|             i=0 | ||||
|             for l in tmp: | ||||
|                 classes=[] | ||||
|                 i+=1 | ||||
|                 if i in ignored_line_numbers: | ||||
|                     classes.append('ignored') | ||||
|                 if i in trigger_line_numbers: | ||||
|                     classes.append('triggered') | ||||
|                 content.append({'line': l, 'classes': ' '.join(classes)}) | ||||
|  | ||||
|         except Exception as e: | ||||
|             content.append({'line': "File doesnt exist or unable to read file {}".format(filename), 'classes': ''}) | ||||
|             content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''}) | ||||
|  | ||||
|         output = render_template("preview.html", | ||||
|                                  content=content, | ||||
| @@ -1126,7 +1174,8 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         add_paused = request.form.get('edit_and_watch_submit_button') != None | ||||
|         new_uuid = datastore.add_watch(url=url, tag=request.form.get('tag').strip(), extras={'paused': add_paused}) | ||||
|         processor = request.form.get('processor', 'text_json_diff') | ||||
|         new_uuid = datastore.add_watch(url=url, tag=request.form.get('tag').strip(), extras={'paused': add_paused, 'processor': processor}) | ||||
|  | ||||
|         if new_uuid: | ||||
|             if add_paused: | ||||
| @@ -1168,7 +1217,8 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         new_uuid = datastore.clone(uuid) | ||||
|         if new_uuid: | ||||
|             update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid, 'skip_when_checksum_same': True})) | ||||
|             if not datastore.data['watching'].get(uuid).get('paused'): | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid, 'skip_when_checksum_same': True})) | ||||
|             flash('Cloned.') | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
| @@ -1234,6 +1284,13 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = False | ||||
|             flash("{} watches unpaused".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'mark-viewed'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.set_last_viewed(uuid, int(time.time())) | ||||
|             flash("{} watches updated".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'mute'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
| @@ -1256,6 +1313,13 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False})) | ||||
|             flash("{} watches queued for rechecking".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'clear-history'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.clear_watch_history(uuid) | ||||
|             flash("{} watches cleared/reset.".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'notification-default'): | ||||
|             from changedetectionio.notification import ( | ||||
|                 default_notification_format_for_watch | ||||
|   | ||||
| @@ -179,9 +179,7 @@ class WatchSingleHistory(Resource): | ||||
|         if timestamp == 'latest': | ||||
|             timestamp = list(watch.history.keys())[-1] | ||||
|  | ||||
|         # @todo - Check for UTF-8 compatability | ||||
|         with open(watch.history[timestamp], 'r') as f: | ||||
|             content = f.read() | ||||
|         content = watch.get_history_snapshot(timestamp) | ||||
|  | ||||
|         response = make_response(content, 200) | ||||
|         response.mimetype = "text/plain" | ||||
|   | ||||
| @@ -27,58 +27,103 @@ import os | ||||
| import logging | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio import login_optionally_required | ||||
| browsersteps_live_ui_o = {} | ||||
| browsersteps_playwright_browser_interface = None | ||||
| browsersteps_playwright_browser_interface_browser = None | ||||
| browsersteps_playwright_browser_interface_context = None | ||||
| browsersteps_playwright_browser_interface_end_time = None | ||||
| browsersteps_playwright_browser_interface_start_time = None | ||||
|  | ||||
| def cleanup_playwright_session(): | ||||
| browsersteps_sessions = {} | ||||
| io_interface_context = None | ||||
|  | ||||
|     global browsersteps_live_ui_o | ||||
|     global browsersteps_playwright_browser_interface | ||||
|     global browsersteps_playwright_browser_interface_browser | ||||
|     global browsersteps_playwright_browser_interface_context | ||||
|     global browsersteps_playwright_browser_interface_end_time | ||||
|     global browsersteps_playwright_browser_interface_start_time | ||||
|  | ||||
|     browsersteps_live_ui_o = {} | ||||
|     browsersteps_playwright_browser_interface = None | ||||
|     browsersteps_playwright_browser_interface_browser = None | ||||
|     browsersteps_playwright_browser_interface_end_time = None | ||||
|     browsersteps_playwright_browser_interface_start_time = None | ||||
|  | ||||
|     print("Cleaning up old playwright session because time was up, calling .goodbye()") | ||||
|     try: | ||||
|         browsersteps_playwright_browser_interface_context.goodbye() | ||||
|     except Exception as e: | ||||
|         print ("Got exception in shutdown, probably OK") | ||||
|         print (str(e)) | ||||
|  | ||||
|     browsersteps_playwright_browser_interface_context = None | ||||
|  | ||||
|     print ("Cleaning up old playwright session because time was up - done") | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|  | ||||
|     browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates") | ||||
|  | ||||
|     def start_browsersteps_session(watch_uuid): | ||||
|         from . import nonContext | ||||
|         from . import browser_steps | ||||
|         import time | ||||
|         global browsersteps_sessions | ||||
|         global io_interface_context | ||||
|  | ||||
|  | ||||
|         # We keep the playwright session open for many minutes | ||||
|         seconds_keepalive = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60 | ||||
|  | ||||
|         browsersteps_start_session = {'start_time': time.time()} | ||||
|  | ||||
|         # You can only have one of these running | ||||
|         # This should be very fine to leave running for the life of the application | ||||
|         # @idea - Make it global so the pool of watch fetchers can use it also | ||||
|         if not io_interface_context: | ||||
|             io_interface_context = nonContext.c_sync_playwright() | ||||
|             # Start the Playwright context, which is actually a nodejs sub-process and communicates over STDIN/STDOUT pipes | ||||
|             io_interface_context = io_interface_context.start() | ||||
|  | ||||
|  | ||||
|         # 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) | ||||
|         except Exception as e: | ||||
|             if 'ECONNREFUSED' in str(e): | ||||
|                 return make_response('Unable to start the Playwright Browser session, is it running?', 401) | ||||
|             else: | ||||
|                 return make_response(str(e), 401) | ||||
|  | ||||
|         proxy_id = datastore.get_preferred_proxy_for_watch(uuid=watch_uuid) | ||||
|         proxy = None | ||||
|         if proxy_id: | ||||
|             proxy_url = datastore.proxy_list.get(proxy_id).get('url') | ||||
|             if proxy_url: | ||||
|  | ||||
|                 # Playwright needs separate username and password values | ||||
|                 from urllib.parse import urlparse | ||||
|                 parsed = urlparse(proxy_url) | ||||
|                 proxy = {'server': proxy_url} | ||||
|  | ||||
|                 if parsed.username: | ||||
|                     proxy['username'] = parsed.username | ||||
|  | ||||
|                 if parsed.password: | ||||
|                     proxy['password'] = parsed.password | ||||
|  | ||||
|                 print("Browser Steps: UUID {} selected proxy {}".format(watch_uuid, proxy_url)) | ||||
|  | ||||
|         # Tell Playwright to connect to Chrome and setup a new session via our stepper interface | ||||
|         browsersteps_start_session['browserstepper'] = browser_steps.browsersteps_live_ui( | ||||
|             playwright_browser=browsersteps_start_session['browser'], | ||||
|             proxy=proxy) | ||||
|  | ||||
|         # For test | ||||
|         #browsersteps_start_session['browserstepper'].action_goto_url(value="http://example.com?time="+str(time.time())) | ||||
|  | ||||
|         return browsersteps_start_session | ||||
|  | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @browser_steps_blueprint.route("/browsersteps_update", methods=['GET', 'POST']) | ||||
|     @browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET']) | ||||
|     def browsersteps_start_session(): | ||||
|         # A new session was requested, return sessionID | ||||
|  | ||||
|         import uuid | ||||
|         browsersteps_session_id = str(uuid.uuid4()) | ||||
|  | ||||
|         watch_uuid = request.args.get('uuid') | ||||
|         global browsersteps_sessions | ||||
|  | ||||
|         print("Starting connection with playwright") | ||||
|         logging.debug("browser_steps.py connecting") | ||||
|         browsersteps_sessions[browsersteps_session_id] = start_browsersteps_session() | ||||
|         print("Starting connection with playwright - done") | ||||
|         return {'browsersteps_session_id': browsersteps_session_id} | ||||
|  | ||||
|     # A request for an action was received | ||||
|     @login_optionally_required | ||||
|     @browser_steps_blueprint.route("/browsersteps_update", methods=['POST']) | ||||
|     def browsersteps_ui_update(): | ||||
|         import base64 | ||||
|         import playwright._impl._api_types | ||||
|         import time | ||||
|  | ||||
|         global browsersteps_sessions | ||||
|         from changedetectionio.blueprint.browser_steps import browser_steps | ||||
|  | ||||
|         global browsersteps_live_ui_o, browsersteps_playwright_browser_interface_end_time | ||||
|         global browsersteps_playwright_browser_interface_browser | ||||
|         global browsersteps_playwright_browser_interface | ||||
|         global browsersteps_playwright_browser_interface_start_time | ||||
|  | ||||
|         step_n = None | ||||
|         remaining =0 | ||||
|         uuid = request.args.get('uuid') | ||||
|  | ||||
| @@ -87,13 +132,9 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|         if not browsersteps_session_id: | ||||
|             return make_response('No browsersteps_session_id specified', 500) | ||||
|  | ||||
|         # Because we don't "really" run in a context manager ( we make the playwright interface global/long-living ) | ||||
|         # We need to manage the shutdown when the time is up | ||||
|         if browsersteps_playwright_browser_interface_end_time: | ||||
|             remaining = browsersteps_playwright_browser_interface_end_time-time.time() | ||||
|             if browsersteps_playwright_browser_interface_end_time and remaining <= 0: | ||||
|                 cleanup_playwright_session() | ||||
|                 return make_response('Browser session expired, please reload the Browser Steps interface', 401) | ||||
|         if not browsersteps_sessions.get(browsersteps_session_id): | ||||
|             return make_response('No session exists under that ID', 500) | ||||
|  | ||||
|  | ||||
|         # Actions - step/apply/etc, do the thing and return state | ||||
|         if request.method == 'POST': | ||||
| @@ -106,18 +147,13 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|  | ||||
|             if step_operation == 'Goto site': | ||||
|                 step_operation = 'goto_url' | ||||
|                 step_optional_value = None | ||||
|                 step_selector = datastore.data['watching'][uuid].get('url') | ||||
|                 step_optional_value = datastore.data['watching'][uuid].get('url') | ||||
|                 step_selector = None | ||||
|  | ||||
|             # @todo try.. accept.. nice errors not popups.. | ||||
|             try: | ||||
|  | ||||
|                 this_session = browsersteps_live_ui_o.get(browsersteps_session_id) | ||||
|                 if not this_session: | ||||
|                     print("Browser exited") | ||||
|                     return make_response('Browser session ran out of time :( Please reload this page.', 401) | ||||
|  | ||||
|                 this_session.call_action(action_name=step_operation, | ||||
|                 browsersteps_sessions[browsersteps_session_id]['browserstepper'].call_action(action_name=step_operation, | ||||
|                                          selector=step_selector, | ||||
|                                          optional_value=step_optional_value) | ||||
|  | ||||
| @@ -129,99 +165,43 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|             # Get visual selector ready/update its data (also use the current filter info from the page?) | ||||
|             # When the last 'apply' button was pressed | ||||
|             # @todo this adds overhead because the xpath selection is happening twice | ||||
|             u = this_session.page.url | ||||
|             u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url | ||||
|             if is_last_step and u: | ||||
|                 (screenshot, xpath_data) = this_session.request_visualselector_data() | ||||
|                 (screenshot, xpath_data) = browsersteps_sessions[browsersteps_session_id]['browserstepper'].request_visualselector_data() | ||||
|                 datastore.save_screenshot(watch_uuid=uuid, screenshot=screenshot) | ||||
|                 datastore.save_xpath_data(watch_uuid=uuid, data=xpath_data) | ||||
|  | ||||
|         # Setup interface | ||||
|         if request.method == 'GET': | ||||
| #        if not this_session.page: | ||||
| #            cleanup_playwright_session() | ||||
| #            return make_response('Browser session ran out of time :( Please reload this page.', 401) | ||||
|  | ||||
|             if not browsersteps_playwright_browser_interface: | ||||
|                 print("Starting connection with playwright") | ||||
|                 logging.debug("browser_steps.py connecting") | ||||
|         # Screenshots and other info only needed on requesting a step (POST) | ||||
|         try: | ||||
|             state = browsersteps_sessions[browsersteps_session_id]['browserstepper'].get_current_state() | ||||
|         except playwright._impl._api_types.Error as e: | ||||
|             return make_response("Browser session ran out of time :( Please reload this page."+str(e), 401) | ||||
|  | ||||
|                 global browsersteps_playwright_browser_interface_context | ||||
|                 from . import nonContext | ||||
|                 browsersteps_playwright_browser_interface_context = nonContext.c_sync_playwright() | ||||
|                 browsersteps_playwright_browser_interface = browsersteps_playwright_browser_interface_context.start() | ||||
|         # Use send_file() which is way faster than read/write loop on bytes | ||||
|         import json | ||||
|         from tempfile import mkstemp | ||||
|         from flask import send_file | ||||
|         tmp_fd, tmp_file = mkstemp(text=True, suffix=".json", prefix="changedetectionio-") | ||||
|  | ||||
|                 time.sleep(1) | ||||
|                 # At 20 minutes, some other variable is closing it | ||||
|                 # @todo find out what it is and set it | ||||
|                 seconds_keepalive = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60 | ||||
|         output = json.dumps({'screenshot': "data:image/jpeg;base64,{}".format( | ||||
|             base64.b64encode(state[0]).decode('ascii')), | ||||
|             'xpath_data': state[1], | ||||
|             'session_age_start': browsersteps_sessions[browsersteps_session_id]['browserstepper'].age_start, | ||||
|             'browser_time_remaining': round(remaining) | ||||
|         }) | ||||
|  | ||||
|                 # 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_playwright_browser_interface_browser = browsersteps_playwright_browser_interface.chromium.connect_over_cdp( | ||||
|                         os.getenv('PLAYWRIGHT_DRIVER_URL', '') + keepalive) | ||||
|                 except Exception as e: | ||||
|                     if 'ECONNREFUSED' in str(e): | ||||
|                         return make_response('Unable to start the Playwright session properly, is it running?', 401) | ||||
|         with os.fdopen(tmp_fd, 'w') as f: | ||||
|             f.write(output) | ||||
|  | ||||
|                 browsersteps_playwright_browser_interface_end_time = time.time() + (seconds_keepalive-3) | ||||
|                 print("Starting connection with playwright - done") | ||||
|  | ||||
|             if not browsersteps_live_ui_o.get(browsersteps_session_id): | ||||
|                 # Boot up a new session | ||||
|                 proxy_id = datastore.get_preferred_proxy_for_watch(uuid=uuid) | ||||
|                 proxy = None | ||||
|                 if proxy_id: | ||||
|                     proxy_url = datastore.proxy_list.get(proxy_id).get('url') | ||||
|                     if proxy_url: | ||||
|                         proxy = {'server': proxy_url} | ||||
|                         print("Browser Steps: UUID {} Using proxy {}".format(uuid, proxy_url)) | ||||
|  | ||||
|                 # Begin the new "Playwright Context" that re-uses the playwright interface | ||||
|                 # Each session is a "Playwright Context" as a list, that uses the playwright interface | ||||
|                 browsersteps_live_ui_o[browsersteps_session_id] = browser_steps.browsersteps_live_ui( | ||||
|                     playwright_browser=browsersteps_playwright_browser_interface_browser, | ||||
|                     proxy=proxy) | ||||
|                 this_session = browsersteps_live_ui_o[browsersteps_session_id] | ||||
|  | ||||
|         if not this_session.page: | ||||
|             cleanup_playwright_session() | ||||
|             return make_response('Browser session ran out of time :( Please reload this page.', 401) | ||||
|  | ||||
|         response = None | ||||
|  | ||||
|         if request.method == 'POST': | ||||
|             # Screenshots and other info only needed on requesting a step (POST) | ||||
|             try: | ||||
|                 state = this_session.get_current_state() | ||||
|             except playwright._impl._api_types.Error as e: | ||||
|                 return make_response("Browser session ran out of time :( Please reload this page."+str(e), 401) | ||||
|  | ||||
|             # Use send_file() which is way faster than read/write loop on bytes | ||||
|             import json | ||||
|             from tempfile import mkstemp | ||||
|             from flask import send_file | ||||
|             tmp_fd, tmp_file = mkstemp(text=True, suffix=".json", prefix="changedetectionio-") | ||||
|  | ||||
|             output = json.dumps({'screenshot': "data:image/jpeg;base64,{}".format( | ||||
|                 base64.b64encode(state[0]).decode('ascii')), | ||||
|                 'xpath_data': state[1], | ||||
|                 'session_age_start': this_session.age_start, | ||||
|                 'browser_time_remaining': round(remaining) | ||||
|             }) | ||||
|  | ||||
|             with os.fdopen(tmp_fd, 'w') as f: | ||||
|                 f.write(output) | ||||
|  | ||||
|             response = make_response(send_file(path_or_file=tmp_file, | ||||
|                                                mimetype='application/json; charset=UTF-8', | ||||
|                                                etag=True)) | ||||
|             # No longer needed | ||||
|             os.unlink(tmp_file) | ||||
|  | ||||
|         elif request.method == 'GET': | ||||
|             # Just enough to get the session rolling, it will call for goto-site via POST next | ||||
|             response = make_response({ | ||||
|                 'session_age_start': this_session.age_start, | ||||
|                 'browser_time_remaining': round(remaining) | ||||
|             }) | ||||
|         response = make_response(send_file(path_or_file=tmp_file, | ||||
|                                            mimetype='application/json; charset=UTF-8', | ||||
|                                            etag=True)) | ||||
|         # No longer needed | ||||
|         os.unlink(tmp_file) | ||||
|  | ||||
|         return response | ||||
|  | ||||
|   | ||||
| @@ -25,6 +25,7 @@ browser_step_ui_config = {'Choose one': '0 0', | ||||
|                           'Execute JS': '0 1', | ||||
| #                          'Extract text and use as filter': '1 0', | ||||
|                           'Goto site': '0 0', | ||||
|                           'Goto URL': '0 1', | ||||
|                           'Press Enter': '0 0', | ||||
|                           'Select by label': '1 1', | ||||
|                           'Scroll down': '0 0', | ||||
| @@ -54,7 +55,7 @@ class steppable_browser_interface(): | ||||
|  | ||||
|         print("> action calling", call_action_name) | ||||
|         # https://playwright.dev/python/docs/selectors#xpath-selectors | ||||
|         if selector.startswith('/') and not selector.startswith('//'): | ||||
|         if selector and selector.startswith('/') and not selector.startswith('//'): | ||||
|             selector = "xpath=" + selector | ||||
|  | ||||
|         action_handler = getattr(self, "action_" + call_action_name) | ||||
| @@ -70,13 +71,13 @@ class steppable_browser_interface(): | ||||
|             optional_value = str(jinja2_env.from_string(optional_value).render()) | ||||
|  | ||||
|         action_handler(selector, optional_value) | ||||
|         self.page.wait_for_timeout(3 * 1000) | ||||
|         self.page.wait_for_timeout(1.5 * 1000) | ||||
|         print("Call action done in", time.time() - now) | ||||
|  | ||||
|     def action_goto_url(self, url, optional_value): | ||||
|     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(url, timeout=0, wait_until='commit') | ||||
|         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. | ||||
| @@ -104,7 +105,8 @@ class steppable_browser_interface(): | ||||
|         print("Clicking element") | ||||
|         if not len(selector.strip()): | ||||
|             return | ||||
|         self.page.click(selector, timeout=10 * 1000, delay=randint(200, 500)) | ||||
|  | ||||
|         self.page.click(selector=selector, timeout=30 * 1000, delay=randint(200, 500)) | ||||
|  | ||||
|     def action_click_element_if_exists(self, selector, value): | ||||
|         import playwright._impl._api_types as _api_types | ||||
| @@ -136,13 +138,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=30000) | ||||
|         self.page.wait_for_function(f'document.querySelector("body").innerText.includes({v});', timeout=90000) | ||||
|  | ||||
|     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=30000) | ||||
|         self.page.wait_for_function(f'document.querySelector({s}).innerText.includes({v});', timeout=90000) | ||||
|  | ||||
|     # @todo - in the future make some popout interface to capture what needs to be set | ||||
|     # https://playwright.dev/python/docs/api/class-keyboard | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import time | ||||
|  | ||||
| visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4, header, footer, section, article, aside, details, main, nav, section, summary' | ||||
|  | ||||
|  | ||||
| class Non200ErrorCodeReceived(Exception): | ||||
|     def __init__(self, status_code, url, screenshot=None, xpath_data=None, page_html=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
| @@ -24,10 +25,12 @@ class Non200ErrorCodeReceived(Exception): | ||||
|             self.page_text = html_tools.html_to_text(page_html) | ||||
|         return | ||||
|  | ||||
|  | ||||
| class checksumFromPreviousCheckWasTheSame(Exception): | ||||
|     def __init__(self): | ||||
|         return | ||||
|  | ||||
|  | ||||
| class JSActionExceptions(Exception): | ||||
|     def __init__(self, status_code, url, screenshot, message=''): | ||||
|         self.status_code = status_code | ||||
| @@ -36,6 +39,7 @@ class JSActionExceptions(Exception): | ||||
|         self.message = message | ||||
|         return | ||||
|  | ||||
|  | ||||
| class BrowserStepsStepTimout(Exception): | ||||
|     def __init__(self, step_n): | ||||
|         self.step_n = step_n | ||||
| @@ -51,6 +55,7 @@ class PageUnloadable(Exception): | ||||
|         self.message = message | ||||
|         return | ||||
|  | ||||
|  | ||||
| class EmptyReply(Exception): | ||||
|     def __init__(self, status_code, url, screenshot=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
| @@ -59,6 +64,7 @@ class EmptyReply(Exception): | ||||
|         self.screenshot = screenshot | ||||
|         return | ||||
|  | ||||
|  | ||||
| class ScreenshotUnavailable(Exception): | ||||
|     def __init__(self, status_code, url, page_html=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
| @@ -69,6 +75,7 @@ class ScreenshotUnavailable(Exception): | ||||
|             self.page_text = html_to_text(page_html) | ||||
|         return | ||||
|  | ||||
|  | ||||
| class ReplyWithContentButNoText(Exception): | ||||
|     def __init__(self, status_code, url, screenshot=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
| @@ -77,19 +84,20 @@ class ReplyWithContentButNoText(Exception): | ||||
|         self.screenshot = screenshot | ||||
|         return | ||||
|  | ||||
|  | ||||
| class Fetcher(): | ||||
|     error = None | ||||
|     status_code = None | ||||
|     content = None | ||||
|     headers = None | ||||
|     browser_steps = None | ||||
|     browser_steps_screenshot_path = None | ||||
|  | ||||
|     content = None | ||||
|     error = None | ||||
|     fetcher_description = "No description" | ||||
|     headers = {} | ||||
|     status_code = None | ||||
|     webdriver_js_execute_code = None | ||||
|     xpath_element_js = "" | ||||
|  | ||||
|     xpath_data = None | ||||
|     xpath_element_js = "" | ||||
|     instock_data = None | ||||
|     instock_data_js = "" | ||||
|  | ||||
|     # Will be needed in the future by the VisualSelector, always get this where possible. | ||||
|     screenshot = False | ||||
| @@ -103,7 +111,7 @@ class Fetcher(): | ||||
|         from pkg_resources import resource_string | ||||
|         # The code that scrapes elements and makes a list of elements/size/position to click on in the VisualSelector | ||||
|         self.xpath_element_js = resource_string(__name__, "res/xpath_element_scraper.js").decode('utf-8') | ||||
|  | ||||
|         self.instock_data_js = resource_string(__name__, "res/stock-not-in-stock.js").decode('utf-8') | ||||
|  | ||||
|     @abstractmethod | ||||
|     def get_error(self): | ||||
| @@ -151,13 +159,15 @@ class Fetcher(): | ||||
|             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 = filter( | ||||
|                 lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'), | ||||
|                 self.browser_steps) | ||||
|  | ||||
|             for step in valid_steps: | ||||
|                 step_n += 1 | ||||
|                 print(">> Iterating check - browser Step n {} - {}...".format(step_n, step['operation'])) | ||||
|                 self.screenshot_step("before-"+str(step_n)) | ||||
|                 self.save_step_html("before-"+str(step_n)) | ||||
|                 self.screenshot_step("before-" + str(step_n)) | ||||
|                 self.save_step_html("before-" + str(step_n)) | ||||
|                 try: | ||||
|                     optional_value = step['optional_value'] | ||||
|                     selector = step['selector'] | ||||
| @@ -172,12 +182,11 @@ class Fetcher(): | ||||
|                                                       optional_value=optional_value) | ||||
|                     self.screenshot_step(step_n) | ||||
|                     self.save_step_html(step_n) | ||||
|                 except TimeoutError: | ||||
|                 except TimeoutError as e: | ||||
|                     print(str(e)) | ||||
|                     # Stop processing here | ||||
|                     raise BrowserStepsStepTimout(step_n=step_n) | ||||
|  | ||||
|  | ||||
|  | ||||
|     # It's always good to reset these | ||||
|     def delete_browser_steps_screenshots(self): | ||||
|         import glob | ||||
| @@ -187,6 +196,7 @@ class Fetcher(): | ||||
|             for f in files: | ||||
|                 os.unlink(f) | ||||
|  | ||||
|  | ||||
| #   Maybe for the future, each fetcher provides its own diff output, could be used for text, image | ||||
| #   the current one would return javascript output (as we use JS to generate the diff) | ||||
| # | ||||
| @@ -204,6 +214,7 @@ def available_fetchers(): | ||||
|  | ||||
|     return p | ||||
|  | ||||
|  | ||||
| class base_html_playwright(Fetcher): | ||||
|     fetcher_description = "Playwright {}/Javascript".format( | ||||
|         os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize() | ||||
| @@ -267,6 +278,118 @@ class base_html_playwright(Fetcher): | ||||
|         with open(destination, 'w') as f: | ||||
|             f.write(content) | ||||
|  | ||||
|     def run_fetch_browserless_puppeteer(self, | ||||
|             url, | ||||
|             timeout, | ||||
|             request_headers, | ||||
|             request_body, | ||||
|             request_method, | ||||
|             ignore_status_codes=False, | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|  | ||||
|         from pkg_resources import resource_string | ||||
|  | ||||
|         extra_wait_ms = (int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay) * 1000 | ||||
|  | ||||
|         self.xpath_element_js = self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) | ||||
|         code = resource_string(__name__, "res/puppeteer_fetch.js").decode('utf-8') | ||||
|         # In the future inject this is a proper JS package | ||||
|         code = code.replace('%xpath_scrape_code%', self.xpath_element_js) | ||||
|         code = code.replace('%instock_scrape_code%', self.instock_data_js) | ||||
|  | ||||
|         from requests.exceptions import ConnectTimeout, ReadTimeout | ||||
|         wait_browserless_seconds = 240 | ||||
|  | ||||
|         browserless_function_url = os.getenv('BROWSERLESS_FUNCTION_URL') | ||||
|         from urllib.parse import urlparse | ||||
|         if not browserless_function_url: | ||||
|             # Convert/try to guess from PLAYWRIGHT_DRIVER_URL | ||||
|             o = urlparse(os.getenv('PLAYWRIGHT_DRIVER_URL')) | ||||
|             browserless_function_url = o._replace(scheme="http")._replace(path="function").geturl() | ||||
|  | ||||
|  | ||||
|         # Append proxy connect string | ||||
|         if self.proxy: | ||||
|             import urllib.parse | ||||
|             # Remove username/password if it exists in the URL or you will receive "ERR_NO_SUPPORTED_PROXIES" error | ||||
|             # Actual authentication handled by Puppeteer/node | ||||
|             o = urlparse(self.proxy.get('server')) | ||||
|             proxy_url = urllib.parse.quote(o._replace(netloc="{}:{}".format(o.hostname, o.port)).geturl()) | ||||
|             browserless_function_url = f"{browserless_function_url}&--proxy-server={proxy_url}&dumpio=true" | ||||
|  | ||||
|  | ||||
|         try: | ||||
|             amp = '&' if '?' in browserless_function_url else '?' | ||||
|             response = requests.request( | ||||
|                 method="POST", | ||||
|                 json={ | ||||
|                     "code": code, | ||||
|                     "context": { | ||||
|                         # Very primitive disk cache - USE WITH EXTREME CAUTION | ||||
|                         # Run browserless container  with -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" | ||||
|                         'disk_cache_dir': os.getenv("PUPPETEER_DISK_CACHE", False), # or path to disk cache ending in /, ie /tmp/cache/ | ||||
|                         'execute_js': self.webdriver_js_execute_code, | ||||
|                         'extra_wait_ms': extra_wait_ms, | ||||
|                         'include_filters': current_include_filters, | ||||
|                         'req_headers': request_headers, | ||||
|                         'screenshot_quality': int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72)), | ||||
|                         'url': url, | ||||
|                         'user_agent': request_headers.get('User-Agent', 'Mozilla/5.0'), | ||||
|                         'proxy_username': self.proxy.get('username','') if self.proxy else False, | ||||
|                         'proxy_password': self.proxy.get('password', '') if self.proxy else False, | ||||
|                         'no_cache_list': [ | ||||
|                             'twitter', | ||||
|                             '.pdf' | ||||
|                         ], | ||||
|                         # Could use https://github.com/easylist/easylist here, or install a plugin | ||||
|                         'block_url_list': [ | ||||
|                             'adnxs.com', | ||||
|                             'analytics.twitter.com', | ||||
|                             'doubleclick.net', | ||||
|                             'google-analytics.com', | ||||
|                             'googletagmanager', | ||||
|                             'trustpilot.com' | ||||
|                         ] | ||||
|                     } | ||||
|                 }, | ||||
|                 # @todo /function needs adding ws:// to http:// rebuild this | ||||
|                 url=browserless_function_url+f"{amp}--disable-features=AudioServiceOutOfProcess&dumpio=true&--disable-remote-fonts", | ||||
|                 timeout=wait_browserless_seconds) | ||||
|  | ||||
|         except ReadTimeout: | ||||
|             raise PageUnloadable(url=url, status_code=None, message=f"No response from browserless in {wait_browserless_seconds}s") | ||||
|         except ConnectTimeout: | ||||
|             raise PageUnloadable(url=url, status_code=None, message=f"Timed out connecting to browserless, retrying..") | ||||
|         else: | ||||
|             # 200 Here means that the communication to browserless worked only, not the page state | ||||
|             if response.status_code == 200: | ||||
|                 import base64 | ||||
|  | ||||
|                 x = response.json() | ||||
|                 if not x.get('screenshot'): | ||||
|                     # https://github.com/puppeteer/puppeteer/blob/v1.0.0/docs/troubleshooting.md#tips | ||||
|                     # https://github.com/puppeteer/puppeteer/issues/1834 | ||||
|                     # https://github.com/puppeteer/puppeteer/issues/1834#issuecomment-381047051 | ||||
|                     # Check your memory is shared and big enough | ||||
|                     raise ScreenshotUnavailable(url=url, status_code=None) | ||||
|  | ||||
|                 if not x.get('content', '').strip(): | ||||
|                     raise EmptyReply(url=url, status_code=None) | ||||
|  | ||||
|                 if x.get('status_code', 200) != 200 and not ignore_status_codes: | ||||
|                     raise Non200ErrorCodeReceived(url=url, status_code=x.get('status_code', 200), page_html=x['content']) | ||||
|  | ||||
|                 self.content = x.get('content') | ||||
|                 self.headers = x.get('headers') | ||||
|                 self.instock_data = x.get('instock_data') | ||||
|                 self.screenshot = base64.b64decode(x.get('screenshot')) | ||||
|                 self.xpath_data = x.get('xpath_data') | ||||
|  | ||||
|             else: | ||||
|                 # Some other error from browserless | ||||
|                 raise PageUnloadable(url=url, status_code=None, message=response.content.decode('utf-8')) | ||||
|  | ||||
|     def run(self, | ||||
|             url, | ||||
|             timeout, | ||||
| @@ -277,6 +400,24 @@ class base_html_playwright(Fetcher): | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|  | ||||
|         # For now, USE_EXPERIMENTAL_PUPPETEER_FETCH is not supported by watches with BrowserSteps (for now!) | ||||
|         has_browser_steps = self.browser_steps and list(filter( | ||||
|                 lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'), | ||||
|                 self.browser_steps)) | ||||
|  | ||||
|         if not has_browser_steps: | ||||
|             if os.getenv('USE_EXPERIMENTAL_PUPPETEER_FETCH'): | ||||
|                 # Temporary backup solution until we rewrite the playwright code | ||||
|                 return self.run_fetch_browserless_puppeteer( | ||||
|                     url, | ||||
|                     timeout, | ||||
|                     request_headers, | ||||
|                     request_body, | ||||
|                     request_method, | ||||
|                     ignore_status_codes, | ||||
|                     current_include_filters, | ||||
|                     is_binary) | ||||
|  | ||||
|         from playwright.sync_api import sync_playwright | ||||
|         import playwright._impl._api_types | ||||
|  | ||||
| @@ -293,7 +434,7 @@ class base_html_playwright(Fetcher): | ||||
|             # Set user agent to prevent Cloudflare from blocking the browser | ||||
|             # Use the default one configured in the App.py model that's passed from fetch_site_status.py | ||||
|             context = browser.new_context( | ||||
|                 user_agent=request_headers['User-Agent'] if request_headers.get('User-Agent') else 'Mozilla/5.0', | ||||
|                 user_agent=request_headers.get('User-Agent', 'Mozilla/5.0'), | ||||
|                 proxy=self.proxy, | ||||
|                 # This is needed to enable JavaScript execution on GitHub and others | ||||
|                 bypass_csp=True, | ||||
| @@ -323,12 +464,12 @@ class base_html_playwright(Fetcher): | ||||
|             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)) | ||||
|                 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)) | ||||
|                 print("Content Fetcher > Other exception when page.goto", str(e)) | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 raise PageUnloadable(url=url, status_code=None, message=str(e)) | ||||
| @@ -347,7 +488,7 @@ class base_html_playwright(Fetcher): | ||||
|                 # This can be ok, we will try to grab what we could retrieve | ||||
|                 pass | ||||
|             except Exception as e: | ||||
|                 print ("Content Fetcher > Other exception when executing custom JS code", str(e)) | ||||
|                 print("Content Fetcher > Other exception when executing custom JS code", str(e)) | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 raise PageUnloadable(url=url, status_code=None, message=str(e)) | ||||
| @@ -355,7 +496,7 @@ class base_html_playwright(Fetcher): | ||||
|             if response is None: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 print ("Content Fetcher > Response object was none") | ||||
|                 print("Content Fetcher > Response object was none") | ||||
|                 raise EmptyReply(url=url, status_code=None) | ||||
|  | ||||
|             # Run Browser Steps here | ||||
| @@ -369,11 +510,10 @@ class base_html_playwright(Fetcher): | ||||
|             if len(self.page.content().strip()) == 0: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 print ("Content Fetcher > Content was empty") | ||||
|                 print("Content Fetcher > Content was empty") | ||||
|                 raise EmptyReply(url=url, status_code=response.status) | ||||
|  | ||||
|             self.status_code = response.status | ||||
|             self.content = self.page.content() | ||||
|             self.headers = response.all_headers() | ||||
|  | ||||
|             # So we can find an element on the page where its selector was entered manually (maybe not xPath etc) | ||||
| @@ -382,7 +522,9 @@ class base_html_playwright(Fetcher): | ||||
|             else: | ||||
|                 self.page.evaluate("var include_filters=''") | ||||
|  | ||||
|             self.xpath_data = self.page.evaluate("async () => {" + self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) + "}") | ||||
|             self.xpath_data = self.page.evaluate( | ||||
|                 "async () => {" + self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) + "}") | ||||
|             self.instock_data = self.page.evaluate("async () => {" + self.instock_data_js + "}") | ||||
|  | ||||
|             # Bug 3 in Playwright screenshot handling | ||||
|             # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it | ||||
| @@ -393,7 +535,8 @@ class base_html_playwright(Fetcher): | ||||
|             # acceptable screenshot quality here | ||||
|             try: | ||||
|                 # The actual screenshot | ||||
|                 self.screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72))) | ||||
|                 self.screenshot = self.page.screenshot(type='jpeg', full_page=True, | ||||
|                                                        quality=int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72))) | ||||
|             except Exception as e: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
| @@ -402,6 +545,7 @@ class base_html_playwright(Fetcher): | ||||
|             context.close() | ||||
|             browser.close() | ||||
|  | ||||
|  | ||||
| class base_html_webdriver(Fetcher): | ||||
|     if os.getenv("WEBDRIVER_URL"): | ||||
|         fetcher_description = "WebDriver Chrome/Javascript via '{}'".format(os.getenv("WEBDRIVER_URL")) | ||||
|   | ||||
| @@ -10,7 +10,7 @@ def same_slicer(l, a, b): | ||||
|         return l[a:b] | ||||
|  | ||||
| # like .compare but a little different output | ||||
| def customSequenceMatcher(before, after, include_equal=False, include_removed=True, include_added=True): | ||||
| def customSequenceMatcher(before, after, include_equal=False, include_removed=True, include_added=True, include_replaced=True, include_change_type_prefix=True): | ||||
|     cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in " \\t", a=before, b=after) | ||||
|  | ||||
|     # @todo Line-by-line mode instead of buncghed, including `after` that is not in `before` (maybe unset?) | ||||
| @@ -19,33 +19,38 @@ def customSequenceMatcher(before, after, include_equal=False, include_removed=Tr | ||||
|             g = before[alo:ahi] | ||||
|             yield g | ||||
|         elif include_removed and tag == 'delete': | ||||
|             g = ["(removed) " + i for i in same_slicer(before, alo, ahi)] | ||||
|             row_prefix = "(removed) " if include_change_type_prefix else '' | ||||
|             g = [ row_prefix + i for i in same_slicer(before, alo, ahi)] | ||||
|             yield g | ||||
|         elif tag == 'replace': | ||||
|             g = ["(changed) " + i for i in same_slicer(before, alo, ahi)] | ||||
|             g += ["(into) " + i for i in same_slicer(after, blo, bhi)] | ||||
|         elif include_replaced and tag == 'replace': | ||||
|             row_prefix = "(changed) " if include_change_type_prefix else '' | ||||
|             g = [row_prefix + i for i in same_slicer(before, alo, ahi)] | ||||
|             row_prefix = "(into) " if include_change_type_prefix else '' | ||||
|             g += [row_prefix + i for i in same_slicer(after, blo, bhi)] | ||||
|             yield g | ||||
|         elif include_added and tag == 'insert': | ||||
|             g = ["(added) " + i for i in same_slicer(after, blo, bhi)] | ||||
|             row_prefix = "(added) " if include_change_type_prefix else '' | ||||
|             g = [row_prefix + i for i in same_slicer(after, blo, bhi)] | ||||
|             yield g | ||||
|  | ||||
| # only_differences - only return info about the differences, no context | ||||
| # line_feed_sep could be "<br/>" or "<li>" or "\n" etc | ||||
| def render_diff(previous_file, newest_file, include_equal=False, include_removed=True, include_added=True, line_feed_sep="\n"): | ||||
|     with open(newest_file, 'r') as f: | ||||
|         newest_version_file_contents = f.read() | ||||
|         newest_version_file_contents = [line.rstrip() for line in newest_version_file_contents.splitlines()] | ||||
| # line_feed_sep could be "<br>" or "<li>" or "\n" etc | ||||
| def render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=True, include_replaced=True, line_feed_sep="\n", include_change_type_prefix=True): | ||||
|  | ||||
|     if previous_file: | ||||
|         with open(previous_file, 'r') as f: | ||||
|             previous_version_file_contents = f.read() | ||||
|     newest_version_file_contents = [line.rstrip() for line in newest_version_file_contents.splitlines()] | ||||
|  | ||||
|     if previous_version_file_contents: | ||||
|             previous_version_file_contents = [line.rstrip() for line in previous_version_file_contents.splitlines()] | ||||
|     else: | ||||
|         previous_version_file_contents = "" | ||||
|  | ||||
|     rendered_diff = customSequenceMatcher(previous_version_file_contents, | ||||
|                                           newest_version_file_contents, | ||||
|                                           include_equal, include_removed, include_added) | ||||
|     rendered_diff = customSequenceMatcher(before=previous_version_file_contents, | ||||
|                                           after=newest_version_file_contents, | ||||
|                                           include_equal=include_equal, | ||||
|                                           include_removed=include_removed, | ||||
|                                           include_added=include_added, | ||||
|                                           include_replaced=include_replaced, | ||||
|                                           include_change_type_prefix=include_change_type_prefix) | ||||
|  | ||||
|     # Recursively join lists | ||||
|     f = lambda L: line_feed_sep.join([f(x) if type(x) is list else x for x in L]) | ||||
|   | ||||
| @@ -147,12 +147,12 @@ class ValidateContentFetcherIsReady(object): | ||||
|             except urllib3.exceptions.MaxRetryError as e: | ||||
|                 driver_url = some_object.command_executor | ||||
|                 message = field.gettext('Content fetcher \'%s\' did not respond.' % (field.data)) | ||||
|                 message += '<br/>' + field.gettext( | ||||
|                 message += '<br>' + field.gettext( | ||||
|                     'Be sure that the selenium/webdriver runner is running and accessible via network from this container/host.') | ||||
|                 message += '<br/>' + field.gettext('Did you follow the instructions in the wiki?') | ||||
|                 message += '<br/><br/>' + field.gettext('WebDriver Host: %s' % (driver_url)) | ||||
|                 message += '<br/><a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">Go here for more information</a>' | ||||
|                 message += '<br/>'+field.gettext('Content fetcher did not respond properly, unable to use it.\n %s' % (str(e))) | ||||
|                 message += '<br>' + field.gettext('Did you follow the instructions in the wiki?') | ||||
|                 message += '<br><br>' + field.gettext('WebDriver Host: %s' % (driver_url)) | ||||
|                 message += '<br><a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">Go here for more information</a>' | ||||
|                 message += '<br>'+field.gettext('Content fetcher did not respond properly, unable to use it.\n %s' % (str(e))) | ||||
|  | ||||
|                 raise ValidationError(message) | ||||
|  | ||||
| @@ -344,13 +344,15 @@ class ValidateCSSJSONXPATHInput(object): | ||||
|                     raise ValidationError("A system-error occurred when validating your jq expression") | ||||
|  | ||||
| class quickWatchForm(Form): | ||||
|     from . import processors | ||||
|  | ||||
|     url = fields.URLField('URL', validators=[validateURL()]) | ||||
|     tag = StringField('Group tag', [validators.Optional()]) | ||||
|     watch_submit_button = SubmitField('Watch', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|     processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff") | ||||
|     edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|  | ||||
|  | ||||
|  | ||||
| # Common to a single watch and the global settings | ||||
| class commonSettingsForm(Form): | ||||
|     notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers()]) | ||||
| @@ -361,6 +363,10 @@ class commonSettingsForm(Form): | ||||
|     extract_title_as_title = BooleanField('Extract <title> from document and use as watch title', default=False) | ||||
|     webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, | ||||
|                                                                                                                                     message="Should contain one or more seconds")]) | ||||
| class importForm(Form): | ||||
|     from . import processors | ||||
|     processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff") | ||||
|     urls = TextAreaField('URLs') | ||||
|  | ||||
| class SingleBrowserStep(Form): | ||||
|  | ||||
| @@ -393,11 +399,19 @@ class watchForm(commonSettingsForm): | ||||
|     body = TextAreaField('Request body', [validators.Optional()]) | ||||
|     method = SelectField('Request method', choices=valid_method, default=default_method) | ||||
|     ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False) | ||||
|     check_unique_lines = BooleanField('Only trigger when new lines appear', default=False) | ||||
|     check_unique_lines = BooleanField('Only trigger when unique lines appear', default=False) | ||||
|  | ||||
|     filter_text_added = BooleanField('Added lines', default=True) | ||||
|     filter_text_replaced = BooleanField('Replaced/changed lines', default=True) | ||||
|     filter_text_removed = BooleanField('Removed lines', default=True) | ||||
|  | ||||
|     # @todo this class could be moved to its own text_json_diff_watchForm and this goes to restock_diff_Watchform perhaps | ||||
|     in_stock_only = BooleanField('Only trigger when product goes BACK to in-stock', default=True) | ||||
|  | ||||
|     trigger_text = StringListField('Trigger/wait for text', [validators.Optional(), ValidateListRegex()]) | ||||
|     if os.getenv("PLAYWRIGHT_DRIVER_URL"): | ||||
|         browser_steps = FieldList(FormField(SingleBrowserStep), min_entries=10) | ||||
|     text_should_not_be_present = StringListField('Block change-detection if text matches', [validators.Optional(), ValidateListRegex()]) | ||||
|     text_should_not_be_present = StringListField('Block change-detection while text matches', [validators.Optional(), ValidateListRegex()]) | ||||
|     webdriver_js_execute_code = TextAreaField('Execute JavaScript before change detection', render_kw={"rows": "5"}, validators=[validators.Optional()]) | ||||
|  | ||||
|     save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import json | ||||
| import re | ||||
|  | ||||
| # HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis | ||||
| TEXT_FILTER_LIST_LINE_SUFFIX = "<br/>" | ||||
| TEXT_FILTER_LIST_LINE_SUFFIX = "<br>" | ||||
|  | ||||
| # 'price' , 'lowPrice', 'highPrice' are usually under here | ||||
| # all of those may or may not appear on different websites | ||||
| @@ -287,3 +287,18 @@ def workarounds_for_obfuscations(content): | ||||
|     content = re.sub('<!--\s+-->', '', content) | ||||
|  | ||||
|     return content | ||||
|  | ||||
|  | ||||
| def get_triggered_text(content, trigger_text): | ||||
|     triggered_text = [] | ||||
|     result = strip_ignore_text(content=content, | ||||
|                                wordlist=trigger_text, | ||||
|                                mode="line numbers") | ||||
|  | ||||
|     i = 1 | ||||
|     for p in content.splitlines(): | ||||
|         if i in result: | ||||
|             triggered_text.append(p) | ||||
|         i += 1 | ||||
|  | ||||
|     return triggered_text | ||||
|   | ||||
| @@ -29,6 +29,7 @@ class import_url_list(Importer): | ||||
|             data, | ||||
|             flash, | ||||
|             datastore, | ||||
|             processor=None | ||||
|             ): | ||||
|  | ||||
|         urls = data.split("\n") | ||||
| @@ -52,7 +53,11 @@ class import_url_list(Importer): | ||||
|             # Flask wtform validators wont work with basic auth, use validators package | ||||
|             # Up to 5000 per batch so we dont flood the server | ||||
|             if len(url) and validators.url(url.replace('source:', '')) and good < 5000: | ||||
|                 new_uuid = datastore.add_watch(url=url.strip(), tag=tags, write_to_disk_now=False) | ||||
|                 extras = None | ||||
|                 if processor: | ||||
|                     extras = {'processor': processor} | ||||
|                 new_uuid = datastore.add_watch(url=url.strip(), tag=tags, write_to_disk_now=False, extras=extras) | ||||
|  | ||||
|                 if new_uuid: | ||||
|                     # Straight into the queue. | ||||
|                     self.new_uuids.append(new_uuid) | ||||
|   | ||||
| @@ -20,15 +20,21 @@ base_config = { | ||||
|     'body': None, | ||||
|     'check_unique_lines': False,  # On change-detected, compare against all history if its something new | ||||
|     'check_count': 0, | ||||
|     'date_created': None, | ||||
|     'consecutive_filter_failures': 0,  # Every time the CSS/xPath filter cannot be located, reset when all is fine. | ||||
|     'extract_text': [],  # Extract text by regex after filters | ||||
|     'extract_title_as_title': False, | ||||
|     'fetch_backend': 'system', | ||||
|     'fetch_backend': 'system', # plaintext, playwright etc | ||||
|     '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, | ||||
|     'filter_text_replaced': True, | ||||
|     'filter_text_removed': True, | ||||
|     'has_ldjson_price_data': None, | ||||
|     'track_ldjson_price_data': None, | ||||
|     'headers': {},  # Extra headers to send | ||||
|     'ignore_text': [],  # List of text to ignore when calculating the comparison checksum | ||||
|     'in_stock_only' : True, # Only trigger change on going to instock from out-of-stock | ||||
|     'include_filters': [], | ||||
|     'last_checked': 0, | ||||
|     'last_error': False, | ||||
| @@ -239,9 +245,32 @@ class model(dict): | ||||
|         bump = self.history | ||||
|         return self.__newest_history_key | ||||
|  | ||||
|     def get_history_snapshot(self, timestamp): | ||||
|         import brotli | ||||
|         filepath = self.history[timestamp] | ||||
|  | ||||
|         # See if a brotli versions exists and switch to that | ||||
|         if not filepath.endswith('.br') and os.path.isfile(f"{filepath}.br"): | ||||
|             filepath = f"{filepath}.br" | ||||
|  | ||||
|         # OR in the backup case that the .br does not exist, but the plain one does | ||||
|         if filepath.endswith('.br') and not os.path.isfile(filepath): | ||||
|             if os.path.isfile(filepath.replace('.br', '')): | ||||
|                 filepath = filepath.replace('.br', '') | ||||
|  | ||||
|         if filepath.endswith('.br'): | ||||
|             # Brotli doesnt have a fileheader to detect it, so we rely on filename | ||||
|             # https://www.rfc-editor.org/rfc/rfc7932 | ||||
|             with open(filepath, 'rb') as f: | ||||
|                 return(brotli.decompress(f.read()).decode('utf-8')) | ||||
|  | ||||
|         with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: | ||||
|             return f.read() | ||||
|  | ||||
|     # Save some text file to the appropriate path and bump the history | ||||
|     # result_obj from fetch_site_status.run() | ||||
|     def save_history_text(self, contents, timestamp, snapshot_id): | ||||
|         import brotli | ||||
|  | ||||
|         self.ensure_data_dir_exists() | ||||
|  | ||||
| @@ -250,16 +279,21 @@ class model(dict): | ||||
|         if self.__newest_history_key and int(timestamp) == int(self.__newest_history_key): | ||||
|             time.sleep(timestamp - self.__newest_history_key) | ||||
|  | ||||
|         snapshot_fname = f"{snapshot_id}.txt" | ||||
|         threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024)) | ||||
|         skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False')) | ||||
|  | ||||
|         # Only write if it does not exist, this is so that we dont bother re-saving the same data by checksum under different filenames. | ||||
|         dest = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|         if not os.path.exists(dest): | ||||
|             # in /diff/ and /preview/ we are going to assume for now that it's UTF-8 when reading | ||||
|             # most sites are utf-8 and some are even broken utf-8 | ||||
|             with open(dest, 'wb') as f: | ||||
|                 f.write(contents) | ||||
|                 f.close() | ||||
|         if not skip_brotli and len(contents) > threshold: | ||||
|             snapshot_fname = f"{snapshot_id}.txt.br" | ||||
|             dest = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|             if not os.path.exists(dest): | ||||
|                 with open(dest, 'wb') as f: | ||||
|                     f.write(brotli.compress(contents, mode=brotli.MODE_TEXT)) | ||||
|         else: | ||||
|             snapshot_fname = f"{snapshot_id}.txt" | ||||
|             dest = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|             if not os.path.exists(dest): | ||||
|                 with open(dest, 'wb') as f: | ||||
|                     f.write(contents) | ||||
|  | ||||
|         # Append to index | ||||
|         # @todo check last char was \n | ||||
| @@ -296,7 +330,8 @@ class model(dict): | ||||
|         # Compare each lines (set) against each history text file (set) looking for something new.. | ||||
|         existing_history = set({}) | ||||
|         for k, v in self.history.items(): | ||||
|             alist = set([line.decode('utf-8').strip().lower() for line in open(v, 'rb')]) | ||||
|             content = self.get_history_snapshot(k) | ||||
|             alist = set([line.strip().lower() for line in content.splitlines()]) | ||||
|             existing_history = existing_history.union(alist) | ||||
|  | ||||
|         # Check that everything in local_lines(new stuff) already exists in existing_history - it should | ||||
| @@ -311,17 +346,6 @@ class model(dict): | ||||
|         # False is not an option for AppRise, must be type None | ||||
|         return None | ||||
|  | ||||
|     def get_screenshot_as_jpeg(self): | ||||
|  | ||||
|         # Created by save_screenshot() | ||||
|         fname = os.path.join(self.watch_data_dir, "last-screenshot.jpg") | ||||
|         if os.path.isfile(fname): | ||||
|             return fname | ||||
|  | ||||
|         # False is not an option for AppRise, must be type None | ||||
|         return None | ||||
|  | ||||
|  | ||||
|     def __get_file_ctime(self, filename): | ||||
|         fname = os.path.join(self.watch_data_dir, filename) | ||||
|         if os.path.isfile(fname): | ||||
| @@ -368,6 +392,7 @@ class model(dict): | ||||
|             return fname | ||||
|         return False | ||||
|  | ||||
|  | ||||
|     def pause(self): | ||||
|         self['paused'] = True | ||||
|  | ||||
| @@ -397,8 +422,8 @@ class model(dict): | ||||
|         # self.history will be keyed with the full path | ||||
|         for k, fname in self.history.items(): | ||||
|             if os.path.isfile(fname): | ||||
|                 with open(fname, "r") as f: | ||||
|                     contents = f.read() | ||||
|                 if True: | ||||
|                     contents = self.get_history_snapshot(k) | ||||
|                     res = re.findall(regex, contents, re.MULTILINE) | ||||
|                     if res: | ||||
|                         if not csv_writer: | ||||
| @@ -434,3 +459,38 @@ class model(dict): | ||||
|     # Return list of tags, stripped and lowercase, used for searching | ||||
|     def all_tags(self): | ||||
|         return [s.strip().lower() for s in self.get('tag','').split(',')] | ||||
|  | ||||
|     def has_special_diff_filter_options_set(self): | ||||
|  | ||||
|         # All False - nothing would be done, so act like it's not processable | ||||
|         if not self.get('filter_text_added', True) and not self.get('filter_text_replaced', True) and not self.get('filter_text_removed', True): | ||||
|             return False | ||||
|  | ||||
|         # Or one is set | ||||
|         if not self.get('filter_text_added', True) or not self.get('filter_text_replaced', True) or not self.get('filter_text_removed', True): | ||||
|             return True | ||||
|  | ||||
|         # None is set | ||||
|         return False | ||||
|  | ||||
|  | ||||
|     def get_last_fetched_before_filters(self): | ||||
|         import brotli | ||||
|         filepath = os.path.join(self.watch_data_dir, 'last-fetched.br') | ||||
|  | ||||
|         if not os.path.isfile(filepath): | ||||
|             # If a previous attempt doesnt yet exist, just snarf the previous snapshot instead | ||||
|             dates = list(self.history.keys()) | ||||
|             if len(dates): | ||||
|                 return self.get_history_snapshot(dates[-1]) | ||||
|             else: | ||||
|                 return '' | ||||
|  | ||||
|         with open(filepath, 'rb') as f: | ||||
|             return(brotli.decompress(f.read()).decode('utf-8')) | ||||
|  | ||||
|     def save_last_fetched_before_filters(self, contents): | ||||
|         import brotli | ||||
|         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)) | ||||
|   | ||||
| @@ -5,17 +5,18 @@ import json | ||||
|  | ||||
| valid_tokens = { | ||||
|     'base_url': '', | ||||
|     'watch_url': '', | ||||
|     'watch_uuid': '', | ||||
|     'watch_title': '', | ||||
|     'watch_tag': '', | ||||
|     'current_snapshot': '', | ||||
|     'diff': '', | ||||
|     'diff_added': '', | ||||
|     'diff_removed': '', | ||||
|     'diff_full': '', | ||||
|     'diff_removed': '', | ||||
|     'diff_url': '', | ||||
|     'preview_url': '', | ||||
|     'current_snapshot': '' | ||||
|     'triggered_text': '', | ||||
|     'watch_tag': '', | ||||
|     'watch_title': '', | ||||
|     'watch_url': '', | ||||
|     'watch_uuid': '', | ||||
| } | ||||
|  | ||||
| default_notification_format_for_watch = 'System default' | ||||
| @@ -88,7 +89,7 @@ def process_notification(n_object, datastore): | ||||
|     n_body = jinja2_env.from_string(n_object.get('notification_body', default_notification_body)).render(**notification_parameters) | ||||
|     n_title = jinja2_env.from_string(n_object.get('notification_title', default_notification_title)).render(**notification_parameters) | ||||
|     n_format = valid_notification_formats.get( | ||||
|         n_object['notification_format'], | ||||
|         n_object.get('notification_format', default_notification_format), | ||||
|         valid_notification_formats[default_notification_format], | ||||
|     ) | ||||
|      | ||||
| @@ -122,10 +123,10 @@ def process_notification(n_object, datastore): | ||||
|                     url += k + 'avatar_url=https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png' | ||||
|  | ||||
|                 if url.startswith('tgram://'): | ||||
|                     # Telegram only supports a limit subset of HTML, remove the '<br/>' we place in. | ||||
|                     # Telegram only supports a limit subset of HTML, remove the '<br>' we place in. | ||||
|                     # re https://github.com/dgtlmoon/changedetection.io/issues/555 | ||||
|                     # @todo re-use an existing library we have already imported to strip all non-allowed tags | ||||
|                     n_body = n_body.replace('<br/>', '\n') | ||||
|                     n_body = n_body.replace('<br>', '\n') | ||||
|                     n_body = n_body.replace('</br>', '\n') | ||||
|                     # real limit is 4096, but minus some for extra metadata | ||||
|                     payload_max_size = 3600 | ||||
| @@ -211,17 +212,18 @@ def create_notification_parameters(n_object, datastore): | ||||
|     tokens.update( | ||||
|         { | ||||
|             'base_url': base_url if base_url is not None else '', | ||||
|             'watch_url': watch_url, | ||||
|             'watch_uuid': uuid, | ||||
|             'watch_title': watch_title if watch_title is not None else '', | ||||
|             'watch_tag': watch_tag if watch_tag is not None else '', | ||||
|             'diff_url': diff_url, | ||||
|             'current_snapshot': n_object['current_snapshot'] if 'current_snapshot' in n_object else '', | ||||
|             'diff': n_object.get('diff', ''),  # Null default in the case we use a test | ||||
|             'diff_added': n_object.get('diff_added', ''),  # Null default in the case we use a test | ||||
|             'diff_removed': n_object.get('diff_removed', ''),  # Null default in the case we use a test | ||||
|             'diff_full': n_object.get('diff_full', ''),  # Null default in the case we use a test | ||||
|             'diff_removed': n_object.get('diff_removed', ''),  # Null default in the case we use a test | ||||
|             'diff_url': diff_url, | ||||
|             'preview_url': preview_url, | ||||
|             'current_snapshot': n_object['current_snapshot'] if 'current_snapshot' in n_object else '' | ||||
|             'triggered_text': n_object.get('triggered_text', ''), | ||||
|             'watch_tag': watch_tag if watch_tag is not None else '', | ||||
|             'watch_title': watch_title if watch_title is not None else '', | ||||
|             'watch_url': watch_url, | ||||
|             'watch_uuid': uuid, | ||||
|         }) | ||||
|  | ||||
|     return tokens | ||||
|   | ||||
							
								
								
									
										11
									
								
								changedetectionio/processors/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								changedetectionio/processors/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| # Change detection post-processors | ||||
|  | ||||
| The concept here is to be able to switch between different domain specific problems to solve. | ||||
|  | ||||
| - `text_json_diff` The traditional text and JSON comparison handler | ||||
| - `restock_diff` Only cares about detecting if a product looks like it has some text that suggests that it's out of stock, otherwise assumes that it's in stock. | ||||
|  | ||||
| Some suggestions for the future | ||||
|  | ||||
| - `graphical`  | ||||
| - `restock_and_price` - extract price AND stock text | ||||
							
								
								
									
										24
									
								
								changedetectionio/processors/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								changedetectionio/processors/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| from abc import abstractmethod | ||||
| import hashlib | ||||
|  | ||||
|  | ||||
| class difference_detection_processor(): | ||||
|  | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     @abstractmethod | ||||
|     def run(self, uuid, skip_when_checksum_same=True): | ||||
|         update_obj = {'last_notification_error': False, 'last_error': False} | ||||
|         some_data = 'xxxxx' | ||||
|         update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest() | ||||
|         changed_detected = False | ||||
|         return changed_detected, update_obj, ''.encode('utf-8') | ||||
|  | ||||
|  | ||||
| def available_processors(): | ||||
|     from . import restock_diff, text_json_diff | ||||
|     x=[('text_json_diff', text_json_diff.name), ('restock_diff', restock_diff.name)] | ||||
|     # @todo Make this smarter with introspection of sorts. | ||||
|     return x | ||||
							
								
								
									
										125
									
								
								changedetectionio/processors/restock_diff.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								changedetectionio/processors/restock_diff.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
|  | ||||
| import hashlib | ||||
| import os | ||||
| import re | ||||
| import urllib3 | ||||
| from . import difference_detection_processor | ||||
| from changedetectionio import content_fetcher | ||||
| from copy import deepcopy | ||||
|  | ||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
|  | ||||
| name = 'Re-stock detection for single product pages' | ||||
| description = 'Detects if the product goes back to in-stock' | ||||
|  | ||||
| class perform_site_check(difference_detection_processor): | ||||
|     screenshot = None | ||||
|     xpath_data = None | ||||
|  | ||||
|     def __init__(self, *args, datastore, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.datastore = datastore | ||||
|  | ||||
|     def run(self, uuid, skip_when_checksum_same=True): | ||||
|  | ||||
|         # DeepCopy so we can be sure we don't accidently change anything by reference | ||||
|         watch = deepcopy(self.datastore.data['watching'].get(uuid)) | ||||
|  | ||||
|         if not watch: | ||||
|             raise Exception("Watch no longer exists.") | ||||
|  | ||||
|         # Protect against file:// access | ||||
|         if re.search(r'^file', watch.get('url', ''), re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False): | ||||
|             raise Exception( | ||||
|                 "file:// type access is denied for security reasons." | ||||
|             ) | ||||
|  | ||||
|         # Unset any existing notification error | ||||
|         update_obj = {'last_notification_error': False, 'last_error': False} | ||||
|         extra_headers = watch.get('headers', []) | ||||
|  | ||||
|         # Tweak the base config with the per-watch ones | ||||
|         request_headers = deepcopy(self.datastore.data['settings']['headers']) | ||||
|         request_headers.update(extra_headers) | ||||
|  | ||||
|         # https://github.com/psf/requests/issues/4525 | ||||
|         # Requests doesnt yet support brotli encoding, so don't put 'br' here, be totally sure that the user cannot | ||||
|         # do this by accident. | ||||
|         if 'Accept-Encoding' in request_headers and "br" in request_headers['Accept-Encoding']: | ||||
|             request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '') | ||||
|  | ||||
|         timeout = self.datastore.data['settings']['requests'].get('timeout') | ||||
|  | ||||
|         url = watch.link | ||||
|  | ||||
|         request_body = self.datastore.data['watching'][uuid].get('body') | ||||
|         request_method = self.datastore.data['watching'][uuid].get('method') | ||||
|         ignore_status_codes = self.datastore.data['watching'][uuid].get('ignore_status_codes', False) | ||||
|  | ||||
|         # Pluggable content fetcher | ||||
|         prefer_backend = watch.get_fetch_backend | ||||
|         if not prefer_backend or prefer_backend == 'system': | ||||
|             prefer_backend = self.datastore.data['settings']['application']['fetch_backend'] | ||||
|  | ||||
|         if hasattr(content_fetcher, prefer_backend): | ||||
|             klass = getattr(content_fetcher, prefer_backend) | ||||
|         else: | ||||
|             # If the klass doesnt exist, just use a default | ||||
|             klass = getattr(content_fetcher, "html_requests") | ||||
|  | ||||
|         proxy_id = self.datastore.get_preferred_proxy_for_watch(uuid=uuid) | ||||
|         proxy_url = None | ||||
|         if proxy_id: | ||||
|             proxy_url = self.datastore.proxy_list.get(proxy_id).get('url') | ||||
|             print("UUID {} Using proxy {}".format(uuid, proxy_url)) | ||||
|  | ||||
|         fetcher = klass(proxy_override=proxy_url) | ||||
|  | ||||
|         # Configurable per-watch or global extra delay before extracting text (for webDriver types) | ||||
|         system_webdriver_delay = self.datastore.data['settings']['application'].get('webdriver_delay', None) | ||||
|         if watch['webdriver_delay'] is not None: | ||||
|             fetcher.render_extract_delay = watch.get('webdriver_delay') | ||||
|         elif system_webdriver_delay is not None: | ||||
|             fetcher.render_extract_delay = system_webdriver_delay | ||||
|  | ||||
|         # Could be removed if requests/plaintext could also return some info? | ||||
|         if prefer_backend != 'html_webdriver': | ||||
|             raise Exception("Re-stock detection requires Chrome or compatible webdriver/playwright fetcher to work") | ||||
|  | ||||
|         if watch.get('webdriver_js_execute_code') is not None and watch.get('webdriver_js_execute_code').strip(): | ||||
|             fetcher.webdriver_js_execute_code = watch.get('webdriver_js_execute_code') | ||||
|  | ||||
|         fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_codes, watch.get('include_filters')) | ||||
|         fetcher.quit() | ||||
|  | ||||
|         self.screenshot = fetcher.screenshot | ||||
|         self.xpath_data = fetcher.xpath_data | ||||
|  | ||||
|         # Track the content type | ||||
|         update_obj['content_type'] = fetcher.headers.get('Content-Type', '') | ||||
|         update_obj["last_check_status"] = fetcher.get_last_status_code() | ||||
|  | ||||
|         # Main detection method | ||||
|         fetched_md5 = None | ||||
|         if fetcher.instock_data: | ||||
|             fetched_md5 = hashlib.md5(fetcher.instock_data.encode('utf-8')).hexdigest() | ||||
|             # 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold. | ||||
|             update_obj["in_stock"] = True if fetcher.instock_data == 'Possibly in stock' else False | ||||
|  | ||||
|  | ||||
|         # The main thing that all this at the moment comes down to :) | ||||
|         changed_detected = False | ||||
|  | ||||
|         if watch.get('previous_md5') and watch.get('previous_md5') != fetched_md5: | ||||
|             # Yes if we only care about it going to instock, AND we are in stock | ||||
|             if watch.get('in_stock_only') and update_obj["in_stock"]: | ||||
|                 changed_detected = True | ||||
|  | ||||
|             if not watch.get('in_stock_only'): | ||||
|                 # All cases | ||||
|                 changed_detected = True | ||||
|  | ||||
|         # Always record the new checksum | ||||
|         update_obj["previous_md5"] = fetched_md5 | ||||
|  | ||||
|         return changed_detected, update_obj, fetcher.instock_data.encode('utf-8') | ||||
| @@ -1,3 +1,5 @@ | ||||
| # HTML to TEXT/JSON DIFFERENCE FETCHER | ||||
| 
 | ||||
| import hashlib | ||||
| import json | ||||
| import logging | ||||
| @@ -8,10 +10,14 @@ import urllib3 | ||||
| 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 | ||||
| 
 | ||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
| 
 | ||||
| 
 | ||||
| name =  'Webpage Text/HTML, JSON and PDF changes' | ||||
| description = 'Detects all text changes where possible' | ||||
| 
 | ||||
| class FilterNotFoundInResponse(ValueError): | ||||
|     def __init__(self, msg): | ||||
|         ValueError.__init__(self, msg) | ||||
| @@ -23,7 +29,7 @@ class PDFToHTMLToolNotFound(ValueError): | ||||
| 
 | ||||
| # Some common stuff here that can be moved to a base class | ||||
| # (set_proxy_from_list) | ||||
| class perform_site_check(): | ||||
| class perform_site_check(difference_detection_processor): | ||||
|     screenshot = None | ||||
|     xpath_data = None | ||||
| 
 | ||||
| @@ -53,7 +59,7 @@ class perform_site_check(): | ||||
|         watch = deepcopy(self.datastore.data['watching'].get(uuid)) | ||||
| 
 | ||||
|         if not watch: | ||||
|             return | ||||
|             raise Exception("Watch no longer exists.") | ||||
| 
 | ||||
|         # Protect against file:// access | ||||
|         if re.search(r'^file', watch.get('url', ''), re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False): | ||||
| @@ -273,6 +279,34 @@ class perform_site_check(): | ||||
|         # Re #340 - return the content before the 'ignore text' was applied | ||||
|         text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8') | ||||
| 
 | ||||
| 
 | ||||
|         # @todo whitespace coming from missing rtrim()? | ||||
|         # stripped_text_from_html could be based on their preferences, replace the processed text with only that which they want to know about. | ||||
|         # Rewrite's the processing text based on only what diff result they want to see | ||||
|         if watch.has_special_diff_filter_options_set() and len(watch.history.keys()): | ||||
|             # Now the content comes from the diff-parser and not the returned HTTP traffic, so could be some differences | ||||
|             from .. import diff | ||||
|             # needs to not include (added) etc or it may get used twice | ||||
|             # Replace the processed text with the preferred result | ||||
|             rendered_diff = diff.render_diff(previous_version_file_contents=watch.get_last_fetched_before_filters(), | ||||
|                                                        newest_version_file_contents=stripped_text_from_html, | ||||
|                                                        include_equal=False,  # not the same lines | ||||
|                                                        include_added=watch.get('filter_text_added', True), | ||||
|                                                        include_removed=watch.get('filter_text_removed', True), | ||||
|                                                        include_replaced=watch.get('filter_text_replaced', True), | ||||
|                                                        line_feed_sep="\n", | ||||
|                                                        include_change_type_prefix=False) | ||||
| 
 | ||||
|             watch.save_last_fetched_before_filters(text_content_before_ignored_filter) | ||||
| 
 | ||||
|             if not rendered_diff and stripped_text_from_html: | ||||
|                 # We had some content, but no differences were found | ||||
|                 # Store our new file as the MD5 so it will trigger in the future | ||||
|                 c = hashlib.md5(text_content_before_ignored_filter.translate(None, b'\r\n\t ')).hexdigest() | ||||
|                 return False, {'previous_md5': c}, stripped_text_from_html.encode('utf-8') | ||||
|             else: | ||||
|                 stripped_text_from_html = rendered_diff | ||||
| 
 | ||||
|         # Treat pages with no renderable text content as a change? No by default | ||||
|         empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False) | ||||
|         if not is_json and not empty_pages_are_a_change and len(stripped_text_from_html.strip()) == 0: | ||||
| @@ -331,6 +365,7 @@ class perform_site_check(): | ||||
|             blocked = True | ||||
|             # Filter and trigger works the same, so reuse it | ||||
|             # It should return the line numbers that match | ||||
|             # Unblock flow if the trigger was found (some text remained after stripped what didnt match) | ||||
|             result = html_tools.strip_ignore_text(content=str(stripped_text_from_html), | ||||
|                                                   wordlist=trigger_text, | ||||
|                                                   mode="line numbers") | ||||
							
								
								
									
										183
									
								
								changedetectionio/res/puppeteer_fetch.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								changedetectionio/res/puppeteer_fetch.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,183 @@ | ||||
| module.exports = async ({page, context}) => { | ||||
|  | ||||
|     var { | ||||
|         url, | ||||
|         execute_js, | ||||
|         user_agent, | ||||
|         extra_wait_ms, | ||||
|         req_headers, | ||||
|         include_filters, | ||||
|         xpath_element_js, | ||||
|         screenshot_quality, | ||||
|         proxy_username, | ||||
|         proxy_password, | ||||
|         disk_cache_dir, | ||||
|         no_cache_list, | ||||
|         block_url_list, | ||||
|     } = context; | ||||
|  | ||||
|     await page.setBypassCSP(true) | ||||
|     await page.setExtraHTTPHeaders(req_headers); | ||||
|     await page.setUserAgent(user_agent); | ||||
|     // https://ourcodeworld.com/articles/read/1106/how-to-solve-puppeteer-timeouterror-navigation-timeout-of-30000-ms-exceeded | ||||
|  | ||||
|     await page.setDefaultNavigationTimeout(0); | ||||
|  | ||||
|     if (proxy_username) { | ||||
|         await page.authenticate({ | ||||
|             username: proxy_username, | ||||
|             password: proxy_password | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     await page.setViewport({ | ||||
|         width: 1024, | ||||
|         height: 768, | ||||
|         deviceScaleFactor: 1, | ||||
|     }); | ||||
|  | ||||
|     await page.setRequestInterception(true); | ||||
|     if (disk_cache_dir) { | ||||
|         console.log(">>>>>>>>>>>>>>> LOCAL DISK CACHE ENABLED <<<<<<<<<<<<<<<<<<<<<"); | ||||
|     } | ||||
|     const fs = require('fs'); | ||||
|     const crypto = require('crypto'); | ||||
|  | ||||
|     function file_is_expired(file_path) { | ||||
|         if (!fs.existsSync(file_path)) { | ||||
|             return true; | ||||
|         } | ||||
|         var stats = fs.statSync(file_path); | ||||
|         const now_date = new Date(); | ||||
|         const expire_seconds = 300; | ||||
|         if ((now_date / 1000) - (stats.mtime.getTime() / 1000) > expire_seconds) { | ||||
|             console.log("CACHE EXPIRED: " + file_path); | ||||
|             return true; | ||||
|         } | ||||
|         return false; | ||||
|  | ||||
|     } | ||||
|  | ||||
|     page.on('request', async (request) => { | ||||
|         // General blocking of requests that waste traffic | ||||
|         if (block_url_list.some(substring => request.url().toLowerCase().includes(substring))) return request.abort(); | ||||
|  | ||||
|         if (disk_cache_dir) { | ||||
|             const url = request.url(); | ||||
|             const key = crypto.createHash('md5').update(url).digest("hex"); | ||||
|             const dir_path = disk_cache_dir + key.slice(0, 1) + '/' + key.slice(1, 2) + '/' + key.slice(2, 3) + '/'; | ||||
|  | ||||
|             // https://stackoverflow.com/questions/4482686/check-synchronously-if-file-directory-exists-in-node-js | ||||
|  | ||||
|             if (fs.existsSync(dir_path + key)) { | ||||
|                 console.log("* CACHE HIT , using - " + dir_path + key + " - " + url); | ||||
|                 const cached_data = fs.readFileSync(dir_path + key); | ||||
|                 // @todo headers can come from dir_path+key+".meta" json file | ||||
|                 request.respond({ | ||||
|                     status: 200, | ||||
|                     //contentType: 'text/html', //@todo | ||||
|                     body: cached_data | ||||
|                 }); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|         request.continue(); | ||||
|     }); | ||||
|  | ||||
|  | ||||
|     if (disk_cache_dir) { | ||||
|         page.on('response', async (response) => { | ||||
|             const url = response.url(); | ||||
|             // Basic filtering for sane responses | ||||
|             if (response.request().method() != 'GET' || response.request().resourceType() == 'xhr' || response.request().resourceType() == 'document' || response.status() != 200) { | ||||
|                 console.log("Skipping (not useful) - Status:" + response.status() + " Method:" + response.request().method() + " ResourceType:" + response.request().resourceType() + " " + url); | ||||
|                 return; | ||||
|             } | ||||
|             if (no_cache_list.some(substring => url.toLowerCase().includes(substring))) { | ||||
|                 console.log("Skipping (no_cache_list) - " + url); | ||||
|                 return; | ||||
|             } | ||||
|             if (url.toLowerCase().includes('data:')) { | ||||
|                 console.log("Skipping (embedded-data) - " + url); | ||||
|                 return; | ||||
|             } | ||||
|             response.buffer().then(buffer => { | ||||
|                 if (buffer.length > 100) { | ||||
|                     console.log("Cache - Saving " + response.request().method() + " - " + url + " - " + response.request().resourceType()); | ||||
|  | ||||
|                     const key = crypto.createHash('md5').update(url).digest("hex"); | ||||
|                     const dir_path = disk_cache_dir + key.slice(0, 1) + '/' + key.slice(1, 2) + '/' + key.slice(2, 3) + '/'; | ||||
|  | ||||
|                     if (!fs.existsSync(dir_path)) { | ||||
|                         fs.mkdirSync(dir_path, {recursive: true}) | ||||
|                     } | ||||
|  | ||||
|                     if (fs.existsSync(dir_path + key)) { | ||||
|                         if (file_is_expired(dir_path + key)) { | ||||
|                             fs.writeFileSync(dir_path + key, buffer); | ||||
|                         } | ||||
|                     } else { | ||||
|                         fs.writeFileSync(dir_path + key, buffer); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     const r = await page.goto(url, { | ||||
|         waitUntil: 'load' | ||||
|     }); | ||||
|  | ||||
|     await page.waitForTimeout(1000); | ||||
|     await page.waitForTimeout(extra_wait_ms); | ||||
|  | ||||
|     if (execute_js) { | ||||
|         await page.evaluate(execute_js); | ||||
|         await page.waitForTimeout(200); | ||||
|     } | ||||
|  | ||||
|     var xpath_data; | ||||
|     var instock_data; | ||||
|     try { | ||||
|         // Not sure the best way here, in the future this should be a new package added to npm then run in browserless | ||||
|         // (Once the old playwright is removed) | ||||
|         xpath_data = await page.evaluate((include_filters) => {%xpath_scrape_code%}, include_filters); | ||||
|         instock_data = await page.evaluate(() => {%instock_scrape_code%}); | ||||
|     } catch (e) { | ||||
|         console.log(e); | ||||
|     } | ||||
|  | ||||
|     // Protocol error (Page.captureScreenshot): Cannot take screenshot with 0 width can come from a proxy auth failure | ||||
|     // Wrap it here (for now) | ||||
|  | ||||
|     var b64s = false; | ||||
|     try { | ||||
|         b64s = await page.screenshot({encoding: "base64", fullPage: true, quality: screenshot_quality, type: 'jpeg'}); | ||||
|     } catch (e) { | ||||
|         console.log(e); | ||||
|     } | ||||
|  | ||||
|     // May fail on very large pages with 'WARNING: tile memory limits exceeded, some content may not draw' | ||||
|     if (!b64s) { | ||||
|         // @todo after text extract, we can place some overlay text with red background to say 'croppped' | ||||
|         console.error('ERROR: content-fetcher page was maybe too large for a screenshot, reverting to viewport only screenshot'); | ||||
|         try { | ||||
|             b64s = await page.screenshot({encoding: "base64", quality: screenshot_quality, type: 'jpeg'}); | ||||
|         } catch (e) { | ||||
|             console.log(e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     var html = await page.content(); | ||||
|     return { | ||||
|         data: { | ||||
|             'content': html, | ||||
|             'headers': r.headers(), | ||||
|             'instock_data': instock_data, | ||||
|             'screenshot': b64s, | ||||
|             'status_code': r.status(), | ||||
|             'xpath_data': xpath_data | ||||
|         }, | ||||
|         type: 'application/json', | ||||
|     }; | ||||
| }; | ||||
							
								
								
									
										97
									
								
								changedetectionio/res/stock-not-in-stock.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								changedetectionio/res/stock-not-in-stock.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| function isItemInStock() { | ||||
|   // @todo Pass these in so the same list can be used in non-JS fetchers | ||||
|   const outOfStockTexts = [ | ||||
|     '0 in stock', | ||||
|     'agotado', | ||||
|     'artikel zurzeit vergriffen', | ||||
|     'as soon as stock is available', | ||||
|     'available for back order', | ||||
|     'backordered', | ||||
|     'brak na stanie', | ||||
|     'brak w magazynie', | ||||
|     'coming soon', | ||||
|     'currently unavailable', | ||||
|     'en rupture de stock', | ||||
|     'item is no longer available', | ||||
|     'message if back in stock', | ||||
|     'nachricht bei', | ||||
|     'nicht auf lager', | ||||
|     'nicht lieferbar', | ||||
|     'nicht zur verfügung', | ||||
|     'no disponible temporalmente', | ||||
|     'no longer in stock', | ||||
|     'not available', | ||||
|     'not in stock', | ||||
|     'notify me when available', | ||||
|     'não estamos a aceitar encomendas', | ||||
|     'out of stock', | ||||
|     'out-of-stock', | ||||
|     'produkt niedostępny', | ||||
|     'sold out', | ||||
|     'temporarily out of stock', | ||||
|     'temporarily unavailable', | ||||
|     'we do not currently have an estimate of when this product will be back in stock.', | ||||
|     'zur zeit nicht an lager', | ||||
|   ]; | ||||
|  | ||||
|  | ||||
|   const negateOutOfStockRegexs = [ | ||||
|       '[0-9] in stock' | ||||
|   ] | ||||
|   var negateOutOfStockRegexs_r = []; | ||||
|   for (let i = 0; i < negateOutOfStockRegexs.length; i++) { | ||||
|     negateOutOfStockRegexs_r.push(new RegExp(negateOutOfStockRegexs[0], 'g')); | ||||
|   } | ||||
|  | ||||
|  | ||||
|   const elementsWithZeroChildren = Array.from(document.getElementsByTagName('*')).filter(element => element.children.length === 0); | ||||
|  | ||||
|   // REGEXS THAT REALLY MEAN IT'S IN STOCK | ||||
|   for (let i = elementsWithZeroChildren.length - 1; i >= 0; i--) { | ||||
|     const element = elementsWithZeroChildren[i]; | ||||
|     if (element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0) { | ||||
|       var elementText=""; | ||||
|       if (element.tagName.toLowerCase() === "input") { | ||||
|         elementText = element.value.toLowerCase(); | ||||
|       } else { | ||||
|         elementText = element.textContent.toLowerCase(); | ||||
|       } | ||||
|  | ||||
|       if (elementText.length) { | ||||
|         // try which ones could mean its in stock | ||||
|         for (let i = 0; i < negateOutOfStockRegexs.length; i++) { | ||||
|           if (negateOutOfStockRegexs_r[i].test(elementText)) { | ||||
|             return 'Possibly in stock'; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // OTHER STUFF THAT COULD BE THAT IT'S OUT OF STOCK | ||||
|   for (let i = elementsWithZeroChildren.length - 1; i >= 0; i--) { | ||||
|     const element = elementsWithZeroChildren[i]; | ||||
|     if (element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0) { | ||||
|       var elementText=""; | ||||
|       if (element.tagName.toLowerCase() === "input") { | ||||
|         elementText = element.value.toLowerCase(); | ||||
|       } else { | ||||
|         elementText = element.textContent.toLowerCase(); | ||||
|       } | ||||
|  | ||||
|       if (elementText.length) { | ||||
|         // and these mean its out of stock | ||||
|         for (const outOfStockText of outOfStockTexts) { | ||||
|           if (elementText.includes(outOfStockText)) { | ||||
|             return elementText; // item is out of stock | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return 'Possibly in stock'; // possibly in stock, cant decide otherwise. | ||||
| } | ||||
|  | ||||
| // returns the element text that makes it think it's out of stock | ||||
| return isItemInStock(); | ||||
| @@ -8,8 +8,14 @@ | ||||
| // Some pages like https://www.londonstockexchange.com/stock/NCCL/ncondezi-energy-limited/analysis | ||||
| // will automatically force a scroll somewhere, so include the position offset | ||||
| // Lets hope the position doesnt change while we iterate the bbox's, but this is better than nothing | ||||
| var scroll_y = 0; | ||||
| try { | ||||
|     scroll_y = +document.documentElement.scrollTop || document.body.scrollTop | ||||
| } catch (e) { | ||||
|     console.log(e); | ||||
| } | ||||
|  | ||||
|  | ||||
| var scroll_y=+document.documentElement.scrollTop || document.body.scrollTop | ||||
|  | ||||
| // Include the getXpath script directly, easier than fetching | ||||
| function getxpath(e) { | ||||
| @@ -38,15 +44,15 @@ const findUpTag = (el) => { | ||||
|     if (el.name !== undefined && el.name.length) { | ||||
|         var proposed = el.tagName + "[name=" + el.name + "]"; | ||||
|         var proposed_element = window.document.querySelectorAll(proposed); | ||||
|         if(proposed_element.length) { | ||||
|         if (proposed_element.length) { | ||||
|             if (proposed_element.length === 1) { | ||||
|                 return proposed; | ||||
|             } else { | ||||
|                 // Some sites change ID but name= stays the same, we can hit it if we know the index | ||||
|                 // Find all the elements that match and work out the input[n] | ||||
|                 var n=Array.from(proposed_element).indexOf(el); | ||||
|                 var n = Array.from(proposed_element).indexOf(el); | ||||
|                 // Return a Playwright selector for nthinput[name=zipcode] | ||||
|                 return proposed+" >> nth="+n; | ||||
|                 return proposed + " >> nth=" + n; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -28,3 +28,11 @@ pytest tests/test_notification.py | ||||
| # Re-run with HIDE_REFERER set - could affect login | ||||
| export HIDE_REFERER=True | ||||
| pytest tests/test_access_control.py | ||||
|  | ||||
| # Re-run a few tests that will trigger brotli based storage | ||||
| export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5 | ||||
| pytest tests/test_access_control.py | ||||
| pytest tests/test_notification.py | ||||
| pytest tests/test_backend.py | ||||
| pytest tests/test_rss.py | ||||
| pytest tests/test_unique_lines.py | ||||
							
								
								
									
										37
									
								
								changedetectionio/static/images/email.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								changedetectionio/static/images/email.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> | ||||
|  | ||||
| <svg | ||||
|    fill="#FFFFFF" | ||||
|    height="7.5005589" | ||||
|    width="11.248507" | ||||
|    version="1.1" | ||||
|    id="Layer_1" | ||||
|    viewBox="0 0 7.1975545 4.7993639" | ||||
|    xml:space="preserve" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"><defs | ||||
|    id="defs19" /> | ||||
| <g | ||||
|    id="g14" | ||||
|    transform="matrix(-0.01406065,0,0,0.01406065,7.1975543,-1.1990922)"> | ||||
| 	<g | ||||
|    id="g12"> | ||||
| 		<g | ||||
|    id="g10"> | ||||
| 			<path | ||||
|    d="M 468.373,85.28 H 45.333 C 21.227,85.28 0,105.76 0,129.014 V 383.2 c 0,23.147 21.227,43.413 45.333,43.413 h 422.933 c 23.68,0 43.627,-19.84 43.627,-43.413 V 129.014 C 512,105.334 492.053,85.28 468.373,85.28 Z m 0,320 H 45.333 c -12.373,0 -24,-10.773 -24,-22.08 V 129.014 c 0,-11.307 11.84,-22.4 24,-22.4 h 422.933 c 11.733,0 22.293,10.667 22.293,22.4 V 383.2 h 0.107 c 10e-4,11.734 -10.453,22.08 -22.293,22.08 z" | ||||
|    id="path2" /> | ||||
| 			<path | ||||
|    d="m 440.853,153.974 c -3.307,-4.907 -9.92,-6.187 -14.827,-2.987 L 256,264.48 85.973,151.094 c -4.907,-3.2 -11.52,-1.707 -14.72,3.2 -3.093,4.8 -1.813,11.307 2.88,14.507 l 176,117.333 c 3.627,2.347 8.213,2.347 11.84,0 l 176,-117.333 c 4.8,-3.201 6.187,-9.921 2.88,-14.827 z" | ||||
|    id="path4" /> | ||||
| 			<path | ||||
|    d="m 143.573,257.654 c -0.107,0.107 -0.32,0.213 -0.427,0.32 L 68.48,311.307 c -4.907,3.307 -6.187,9.92 -2.88,14.827 3.307,4.907 9.92,6.187 14.827,2.88 0.107,-0.107 0.32,-0.213 0.427,-0.32 l 74.667,-53.333 c 4.907,-3.307 6.187,-9.92 2.88,-14.827 -3.308,-4.907 -9.921,-6.187 -14.828,-2.88 z" | ||||
|    id="path6" /> | ||||
| 			<path | ||||
|    d="m 443.947,311.627 c -0.107,-0.107 -0.32,-0.213 -0.427,-0.32 l -74.667,-53.333 c -4.693,-3.52 -11.413,-2.56 -14.933,2.133 -3.52,4.693 -2.56,11.413 2.133,14.933 0.107,0.107 0.32,0.213 0.427,0.32 l 74.667,53.333 c 4.693,3.52 11.413,2.56 14.933,-2.133 3.52,-4.693 2.56,-11.413 -2.133,-14.933 z" | ||||
|    id="path8" /> | ||||
| 		</g> | ||||
| 	</g> | ||||
| </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.9 KiB | 
							
								
								
									
										3
									
								
								changedetectionio/static/images/generic-icon.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changedetectionio/static/images/generic-icon.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
| <svg width="61.649mm" height="61.649mm" version="1.1" viewBox="0 0 61.649 61.649" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(66.269 -15.463)" fill="#3056d3"><g transform="matrix(1.423 0 0 1.423 101.16 69.23)" fill="#3056d3"><g transform="matrix(.8229 0 0 .8229 -23.378 -2.3935)" fill="#3056d3"><path d="m-88.248-43.007a26.323 26.323 0 0 0-26.323 26.323 26.323 26.323 0 0 0 26.323 26.323 26.323 26.323 0 0 0 26.323-26.323 26.323 26.323 0 0 0-26.323-26.323zm0 2.8417a23.482 23.482 0 0 1 23.482 23.482 23.482 23.482 0 0 1-23.482 23.482 23.482 23.482 0 0 1-23.482-23.482 23.482 23.482 0 0 1 23.482-23.482z"/><g transform="matrix(.26458 0 0 .26458 -115.65 -44.085)"><path d="m33.02 64.43c0.35-0.05 2.04-0.13 2.04-0.13h25.53s3.17 0.32 3.67 0.53c2.5 1.05 3.98 1.89 6.04 3.57 0.72 0.58 4.12 4.01 4.12 4.01l51.67 57.39s1.61 1.65 1.97 1.94c1.2 0.97 2.48 1.96 3.98 2.32 0.5 0.12 2.72 0.21 2.72 0.21h27.32l-8.83-9.04s-1.31-1.65-1.44-1.94c-0.45-0.93-0.59-2.59-0.13-3.51 0.35-0.69 1.46-1.87 2.23-1.98 1.03-0.14 2.12-0.39 3.02 0.14 0.33 0.2 1.64 1.32 1.64 1.32l17.49 17.49s1.35 1.09 1.6 1.6c0.17 0.34 0.29 0.82 0.15 1.18-0.17 0.42-1.42 1.63-1.42 1.63l-0.94 0.98-15.69 16.37s-1.44 1.4-1.79 1.67c-0.76 0.6-1.99 0.89-2.96 0.9-1.03 0-2.62-1.11-3.26-1.91-0.6-0.76-1.1-2.22-0.77-3.13 0.16-0.45 1.28-1.85 1.28-1.85l11.36-11.3-29.47-0.02-1.68 0.09s-4.16-0.66-5.26-1.03c-1.63-0.56-3.44-1.82-4.75-2.93-0.39-0.33-1.8-1.92-1.8-1.92l-51.7-59.28s-2-2.06-2.43-2.43c-1.37-1.17-2-1.62-3.76-2.34-0.44-0.18-3.45-0.55-3.45-0.55l-24.13-0.22s-2.23-0.15-2.61-0.22c-1.08-0.21-2.16-1.07-2.81-1.83-0.79-0.92-0.59-3.06 0.06-4.09 0.57-0.89 2.14-1.52 3.19-1.66z"/><path d="m86.1 109.7-17.13 19.65s-2 2.06-2.43 2.43c-1.37 1.17-2 1.62-3.76 2.34-0.44 0.18-3.45 0.55-3.45 0.55l-24.13 0.22s-2.23 0.15-2.61 0.22c-1.08 0.21-2.16 1.07-2.81 1.83-0.79 0.92-0.59 3.06 0.06 4.09 0.57 0.89 2.14 1.52 3.19 1.66 0.35 0.05 2.04 0.13 2.04 0.13h25.53s3.17-0.32 3.67-0.53c2.5-1.05 3.98-1.89 6.04-3.57 0.72-0.58 4.12-4.01 4.12-4.01l17.38-19.3z"/><path d="m177.81 67.6c-0.17-0.42-1.42-1.63-1.42-1.63l-0.94-0.98-15.69-16.37s-1.44-1.4-1.79-1.67c-0.76-0.6-1.99-0.89-2.96-0.9-1.03 0-2.62 1.11-3.26 1.91-0.6 0.76-1.1 2.22-0.77 3.13 0.16 0.45 1.28 1.85 1.28 1.85l11.36 11.3-29.47 0.02-1.68-0.09s-4.16 0.66-5.26 1.03c-1.63 0.56-3.44 1.82-4.75 2.93-0.39 0.33-1.8 1.92-1.8 1.92l-18.91 21.69 5.98 5.98 18.38-20.41s1.61-1.65 1.97-1.94c1.2-0.97 2.48-1.96 3.98-2.32 0.5-0.12 2.72-0.21 2.72-0.21h27.32l-8.83 9.04s-1.31 1.65-1.44 1.94c-0.45 0.93-0.59 2.59-0.13 3.51 0.35 0.69 1.46 1.87 2.23 1.98 1.03 0.14 2.12 0.39 3.02-0.14 0.33-0.2 1.64-1.32 1.64-1.32l17.49-17.49s1.35-1.09 1.6-1.6c0.17-0.34 0.29-0.82 0.15-1.18z"/></g></g></g></g></svg> | ||||
| After Width: | Height: | Size: 2.7 KiB | 
| @@ -114,11 +114,11 @@ $(document).ready(function () { | ||||
|             e.preventDefault() | ||||
|         }); | ||||
|  | ||||
|         // When the mouse moves we know which element it should be above | ||||
|         // mousedown will link that to the UI (select the right action, highlight etc) | ||||
|         $('#browsersteps-selector-canvas').bind('mousedown', function (e) { | ||||
|             // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent | ||||
|             e.preventDefault() | ||||
|             console.log(e); | ||||
|             console.log("current xpath in index is " + current_selected_i); | ||||
|             last_click_xy = {'x': parseInt((1 / x_scale) * e.offsetX), 'y': parseInt((1 / y_scale) * e.offsetY)} | ||||
|             process_selected(current_selected_i); | ||||
|             current_selected_i = false; | ||||
| @@ -132,6 +132,7 @@ $(document).ready(function () { | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Debounce and find the current most 'interesting' element we are hovering above | ||||
|         $('#browsersteps-selector-canvas').bind('mousemove', function (e) { | ||||
|             if (!xpath_data) { | ||||
|                 return; | ||||
| @@ -151,41 +152,38 @@ $(document).ready(function () { | ||||
|             current_selected_i = false; | ||||
|             // Reverse order - the most specific one should be deeper/"laster" | ||||
|             // Basically, find the most 'deepest' | ||||
|             //$('#browsersteps-selector-canvas').css('cursor', 'pointer'); | ||||
|             for (var i = xpath_data['size_pos'].length; i !== 0; i--) { | ||||
|                 // draw all of them? let them choose somehow? | ||||
|                 var sel = xpath_data['size_pos'][i - 1]; | ||||
|             var possible_elements = []; | ||||
|             xpath_data['size_pos'].forEach(function (item, index) { | ||||
|                 // If we are in a bounding-box | ||||
|                 if (e.offsetY > sel.top * y_scale && e.offsetY < sel.top * y_scale + sel.height * y_scale | ||||
|                 if (e.offsetY > item.top * y_scale && e.offsetY < item.top * y_scale + item.height * y_scale | ||||
|                     && | ||||
|                     e.offsetX > sel.left * y_scale && e.offsetX < sel.left * y_scale + sel.width * y_scale | ||||
|                     e.offsetX > item.left * y_scale && e.offsetX < item.left * y_scale + item.width * y_scale | ||||
|  | ||||
|                 ) { | ||||
|                     // Only highlight these interesting types | ||||
|                     if (1) { | ||||
|                         ctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||
|                         ctx.fillRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||
|                         current_selected_i = i - 1; | ||||
|                         break; | ||||
|  | ||||
|                         // find the smallest one at this x,y | ||||
|                         // does it mean sort the xpath list by size (w*h) i think so! | ||||
|                     } else { | ||||
|  | ||||
|                         if (include_text_elements[0].checked === true) { | ||||
|                             // blue one with background instead? | ||||
|                             ctx.fillStyle = 'rgba(0,0,255, 0.1)'; | ||||
|                             ctx.strokeStyle = 'rgba(0,0,200, 0.7)'; | ||||
|                             $('#browsersteps-selector-canvas').css('cursor', 'grab'); | ||||
|                             ctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||
|                             ctx.fillRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||
|                             current_selected_i = i - 1; | ||||
|                             break; | ||||
|                         } | ||||
|                     } | ||||
|                     // There could be many elements here, record them all and then we'll find out which is the most 'useful' | ||||
|                     // (input, textarea, button, A etc) | ||||
|                     possible_elements.push(item); | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             // Find the best one | ||||
|             if (possible_elements.length) { | ||||
|                 possible_elements.forEach(function (item, index) { | ||||
|                   if (["a", "input", "textarea", "button"].includes(item['tagName'])) { | ||||
|                       current_selected_i = item; | ||||
|                   } | ||||
|                 }); | ||||
|  | ||||
|                 if (!current_selected_i) { | ||||
|                     current_selected_i = possible_elements[0]; | ||||
|                 } | ||||
|  | ||||
|                 sel = xpath_data['size_pos'][current_selected_i]; | ||||
|                 ctx.strokeRect(current_selected_i.left * x_scale, current_selected_i.top * y_scale, current_selected_i.width * x_scale, current_selected_i.height * y_scale); | ||||
|                 ctx.fillRect(current_selected_i.left * x_scale, current_selected_i.top * y_scale, current_selected_i.width * x_scale, current_selected_i.height * y_scale); | ||||
|             } | ||||
|  | ||||
|  | ||||
|         }.debounce(10)); | ||||
|     }); | ||||
|  | ||||
| @@ -195,16 +193,16 @@ $(document).ready(function () { | ||||
|  | ||||
|  | ||||
|     // callback for clicking on an xpath on the canvas | ||||
|     function process_selected(xpath_data_index) { | ||||
|     function process_selected(selected_in_xpath_list) { | ||||
|         found_something = false; | ||||
|         var first_available = $("ul#browser_steps li.empty").first(); | ||||
|  | ||||
|  | ||||
|         if (xpath_data_index !== false) { | ||||
|         if (selected_in_xpath_list !== false) { | ||||
|             // Nothing focused, so fill in a new one | ||||
|             // if inpt type button or <button> | ||||
|             // from the top, find the next not used one and use it | ||||
|             var x = xpath_data['size_pos'][xpath_data_index]; | ||||
|             var x = selected_in_xpath_list; | ||||
|             console.log(x); | ||||
|             if (x && first_available.length) { | ||||
|                 // @todo will it let you click shit that has a layer ontop? probably not. | ||||
| @@ -214,26 +212,18 @@ $(document).ready(function () { | ||||
|                     $('input[placeholder="Value"]', first_available).addClass('ok').click().focus(); | ||||
|                     found_something = true; | ||||
|                 } else { | ||||
|                     if (x['isClickable'] || x['tagName'].startsWith('h') || x['tagName'] === 'a' || x['tagName'] === 'button' || x['tagtype'] === 'submit' || x['tagtype'] === 'checkbox' || x['tagtype'] === 'radio' || x['tagtype'] === 'li') { | ||||
|                     // There's no good way (that I know) to find if this | ||||
|                     // see https://stackoverflow.com/questions/446892/how-to-find-event-listeners-on-a-dom-node-in-javascript-or-in-debugging | ||||
|                     // https://codepen.io/azaslavsky/pen/DEJVWv | ||||
|  | ||||
|                     // So we dont know if its really a clickable element or not :-( | ||||
|                     // Assume it is - then we dont fill the pages with unreliable "Click X,Y" selections | ||||
|                     // If you switch to "Click X,y" after an element here is setup, it will give the last co-ords anyway | ||||
|                     //if (x['isClickable'] || x['tagName'].startsWith('h') || x['tagName'] === 'a' || x['tagName'] === 'button' || x['tagtype'] === 'submit' || x['tagtype'] === 'checkbox' || x['tagtype'] === 'radio' || x['tagtype'] === 'li') { | ||||
|                         $('select', first_available).val('Click element').change(); | ||||
|                         $('input[type=text]', first_available).first().val(x['xpath']); | ||||
|                         found_something = true; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 first_available.xpath_data_index = xpath_data_index; | ||||
|  | ||||
|                 if (!found_something) { | ||||
|                     if (include_text_elements[0].checked === true) { | ||||
|                         // Suggest that we use as filter? | ||||
|                         // @todo filters should always be in the last steps, nothing non-filter after it | ||||
|                         found_something = true; | ||||
|                         ctx.strokeStyle = 'rgba(0,0,255, 0.9)'; | ||||
|                         ctx.fillStyle = 'rgba(0,0,255, 0.1)'; | ||||
|                         $('select', first_available).val('Extract text and use as filter').change(); | ||||
|                         $('input[type=text]', first_available).first().val(x['xpath']); | ||||
|                         include_text_elements[0].checked = false; | ||||
|                     } | ||||
|                     //} | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| @@ -248,7 +238,7 @@ $(document).ready(function () { | ||||
|  | ||||
|     function start() { | ||||
|         console.log("Starting browser-steps UI"); | ||||
|         browsersteps_session_id = Date.now(); | ||||
|         browsersteps_session_id = false; | ||||
|         // @todo This setting of the first one should be done at the datalayer but wtforms doesnt wanna play nice | ||||
|         $('#browser_steps >li:first-child').removeClass('empty'); | ||||
|         set_first_gotosite_disabled(); | ||||
| @@ -256,7 +246,7 @@ $(document).ready(function () { | ||||
|         $('.clear,.remove', $('#browser_steps >li:first-child')).hide(); | ||||
|         $.ajax({ | ||||
|             type: "GET", | ||||
|             url: browser_steps_sync_url + "&browsersteps_session_id=" + browsersteps_session_id, | ||||
|             url: browser_steps_start_url, | ||||
|             statusCode: { | ||||
|                 400: function () { | ||||
|                     // More than likely the CSRF token was lost when the server restarted | ||||
| @@ -264,12 +254,12 @@ $(document).ready(function () { | ||||
|                 } | ||||
|             } | ||||
|         }).done(function (data) { | ||||
|             xpath_data = data.xpath_data; | ||||
|             $("#loading-status-text").fadeIn(); | ||||
|             browsersteps_session_id = data.browsersteps_session_id; | ||||
|             // This should trigger 'Goto site' | ||||
|             console.log("Got startup response, requesting Goto-Site (first) step fake click"); | ||||
|             $('#browser_steps >li:first-child .apply').click(); | ||||
|             browserless_seconds_remaining = data.browser_time_remaining; | ||||
|             browserless_seconds_remaining = 500; | ||||
|             set_first_gotosite_disabled(); | ||||
|         }).fail(function (data) { | ||||
|             console.log(data); | ||||
|   | ||||
| @@ -26,9 +26,6 @@ $(document).ready(function() { | ||||
|     data = { | ||||
|         window_url : window.location.href, | ||||
|         notification_urls : $('.notification-urls').val(), | ||||
|         notification_title : $('.notification-title').val(), | ||||
|         notification_body : $('.notification-body').val(), | ||||
|         notification_format : $('.notification-format').val(), | ||||
|     } | ||||
|     for (key in data) { | ||||
|       if (!data[key].length) { | ||||
|   | ||||
| @@ -12,7 +12,7 @@ window.addEventListener('hashchange', function () { | ||||
| var has_errors = document.querySelectorAll(".messages .error"); | ||||
| if (!has_errors.length) { | ||||
|     if (document.location.hash == "") { | ||||
|         document.querySelector(".tabs ul li:first-child a").click(); | ||||
|         location.replace(document.querySelector(".tabs ul li:first-child a").hash); | ||||
|     } else { | ||||
|         set_active_tab(); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										37
									
								
								changedetectionio/static/styles/scss/parts/_pagination.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								changedetectionio/static/styles/scss/parts/_pagination.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| .pagination-page-info { | ||||
|   color: #fff; | ||||
|   font-size: 0.85rem; | ||||
|   text-transform: capitalize; | ||||
| } | ||||
|  | ||||
| .pagination.menu { | ||||
|   > * { | ||||
|     display: inline-block; | ||||
|   } | ||||
|  | ||||
|   li { | ||||
|     display: inline-block; | ||||
|   } | ||||
|  | ||||
|   a { | ||||
|     padding: 0.65rem; | ||||
|     margin: 3px; | ||||
|     border: none; | ||||
|     background: #444; | ||||
|     border-radius: 2px; | ||||
|     color: var(--color-text-button); | ||||
|     &.disabled { | ||||
|       display: none; | ||||
|     } | ||||
|     &.active { | ||||
|       font-weight: bold; | ||||
|       background: #888; | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|       background: #999; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
| } | ||||
| @@ -5,6 +5,7 @@ | ||||
| @import "parts/_arrows"; | ||||
| @import "parts/_browser-steps"; | ||||
| @import "parts/_extra_proxies"; | ||||
| @import "parts/_pagination"; | ||||
| @import "parts/_spinners"; | ||||
| @import "parts/_variables"; | ||||
|  | ||||
| @@ -241,6 +242,10 @@ body:before { | ||||
|   font-size: 85%; | ||||
| } | ||||
|  | ||||
| .button-xsmall { | ||||
|   font-size: 70%; | ||||
| } | ||||
|  | ||||
| .fetch-error { | ||||
|   padding-top: 1em; | ||||
|   font-size: 80%; | ||||
| @@ -889,6 +894,21 @@ body.full-width { | ||||
|       font-size: .875em; | ||||
|     } | ||||
|   } | ||||
|   .text-filtering { | ||||
|     h3 { | ||||
|       margin-top: 0; | ||||
|     } | ||||
|     border: 1px solid #ccc; | ||||
|     padding: 1rem; | ||||
|     border-radius: 5px; | ||||
|     margin-bottom: 1rem; | ||||
|     fieldset:last-of-type { | ||||
|       padding-bottom: 0; | ||||
|       .pure-control-group { | ||||
|         padding-bottom: 0; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| ul { | ||||
| @@ -1044,3 +1064,30 @@ ul { | ||||
|   vertical-align: middle; | ||||
| } | ||||
|  | ||||
|  | ||||
| #quick-watch-processor-type { | ||||
|   color: #fff; | ||||
|   ul { | ||||
|     padding: 0.3rem; | ||||
|  | ||||
|     li { | ||||
|       list-style: none; | ||||
|       font-size: 0.8rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| .restock-label { | ||||
|   &.in-stock { | ||||
|     background-color: var(--color-background-button-green); | ||||
|     color: #fff; | ||||
|   } | ||||
|   &.not-in-stock { | ||||
|     background-color: var(--color-background-button-cancel); | ||||
|     color: #777; | ||||
|   } | ||||
|   padding: 3px; | ||||
|   border-radius: 3px; | ||||
|   white-space: nowrap; | ||||
| } | ||||
|   | ||||
| @@ -95,6 +95,32 @@ ul#requests-extra_proxies { | ||||
|   ul#requests-extra_proxies table tr { | ||||
|     display: inline; } | ||||
|  | ||||
| .pagination-page-info { | ||||
|   color: #fff; | ||||
|   font-size: 0.85rem; | ||||
|   text-transform: capitalize; } | ||||
|  | ||||
| .pagination.menu > * { | ||||
|   display: inline-block; } | ||||
|  | ||||
| .pagination.menu li { | ||||
|   display: inline-block; } | ||||
|  | ||||
| .pagination.menu a { | ||||
|   padding: 0.65rem; | ||||
|   margin: 3px; | ||||
|   border: none; | ||||
|   background: #444; | ||||
|   border-radius: 2px; | ||||
|   color: var(--color-text-button); } | ||||
|   .pagination.menu a.disabled { | ||||
|     display: none; } | ||||
|   .pagination.menu a.active { | ||||
|     font-weight: bold; | ||||
|     background: #888; } | ||||
|   .pagination.menu a:hover { | ||||
|     background: #999; } | ||||
|  | ||||
| /* spinner */ | ||||
| .spinner, | ||||
| .spinner:after { | ||||
| @@ -432,6 +458,9 @@ body:before { | ||||
| .button-small { | ||||
|   font-size: 85%; } | ||||
|  | ||||
| .button-xsmall { | ||||
|   font-size: 70%; } | ||||
|  | ||||
| .fetch-error { | ||||
|   padding-top: 1em; | ||||
|   font-size: 80%; | ||||
| @@ -869,6 +898,17 @@ body.full-width .edit-form { | ||||
|     color: var(--color-text-input-description); } | ||||
|     .edit-form .pure-form-message-inline code { | ||||
|       font-size: .875em; } | ||||
|   .edit-form .text-filtering { | ||||
|     border: 1px solid #ccc; | ||||
|     padding: 1rem; | ||||
|     border-radius: 5px; | ||||
|     margin-bottom: 1rem; } | ||||
|     .edit-form .text-filtering h3 { | ||||
|       margin-top: 0; } | ||||
|     .edit-form .text-filtering fieldset:last-of-type { | ||||
|       padding-bottom: 0; } | ||||
|       .edit-form .text-filtering fieldset:last-of-type .pure-control-group { | ||||
|         padding-bottom: 0; } | ||||
|  | ||||
| ul { | ||||
|   padding-left: 1em; | ||||
| @@ -980,3 +1020,22 @@ ul { | ||||
|   display: inline-block; | ||||
|   height: 0.8rem; | ||||
|   vertical-align: middle; } | ||||
|  | ||||
| #quick-watch-processor-type { | ||||
|   color: #fff; } | ||||
|   #quick-watch-processor-type ul { | ||||
|     padding: 0.3rem; } | ||||
|     #quick-watch-processor-type ul li { | ||||
|       list-style: none; | ||||
|       font-size: 0.8rem; } | ||||
|  | ||||
| .restock-label { | ||||
|   padding: 3px; | ||||
|   border-radius: 3px; | ||||
|   white-space: nowrap; } | ||||
|   .restock-label.in-stock { | ||||
|     background-color: var(--color-background-button-green); | ||||
|     color: #fff; } | ||||
|   .restock-label.not-in-stock { | ||||
|     background-color: var(--color-background-button-cancel); | ||||
|     color: #777; } | ||||
|   | ||||
| @@ -287,6 +287,7 @@ class ChangeDetectionStore: | ||||
|                     'method', | ||||
|                     'paused', | ||||
|                     'previous_md5', | ||||
|                     'processor', | ||||
|                     'subtractive_selectors', | ||||
|                     'tag', | ||||
|                     'text_should_not_be_present', | ||||
| @@ -315,7 +316,8 @@ class ChangeDetectionStore: | ||||
|             # #Re 569 | ||||
|             new_watch = Watch.model(datastore_path=self.datastore_path, default={ | ||||
|                 'url': url, | ||||
|                 'tag': tag | ||||
|                 'tag': tag, | ||||
|                 'date_created': int(time.time()) | ||||
|             }) | ||||
|  | ||||
|             new_uuid = new_watch['uuid'] | ||||
| @@ -360,11 +362,6 @@ class ChangeDetectionStore: | ||||
|             f.write(screenshot) | ||||
|             f.close() | ||||
|  | ||||
|         # Make a JPEG that's used in notifications (due to being a smaller size) available | ||||
|         from PIL import Image | ||||
|         im1 = Image.open(target_path) | ||||
|         im1.convert('RGB').save(target_path.replace('.png','.jpg'), quality=int(os.getenv("NOTIFICATION_SCREENSHOT_JPG_QUALITY", 75))) | ||||
|  | ||||
|  | ||||
|     def save_error_text(self, watch_uuid, contents): | ||||
|         if not self.data['watching'].get(watch_uuid): | ||||
| @@ -683,3 +680,13 @@ class ChangeDetectionStore: | ||||
|             except: | ||||
|                 continue | ||||
|         return | ||||
|  | ||||
|     # We don't know when the date_created was in the past until now, so just add an index number for now. | ||||
|     def update_11(self): | ||||
|         i = 0 | ||||
|         for uuid, watch in self.data['watching'].items(): | ||||
|             if not watch.get('date_created'): | ||||
|                 watch['date_created'] = i | ||||
|             i+=1 | ||||
|         return | ||||
|  | ||||
|   | ||||
| @@ -17,14 +17,15 @@ | ||||
|                                 <li><code>tgram://</code> bots cant send messages to other bots, so you should specify chat ID of non-bot user.</li> | ||||
|                                 <li><code>tgram://</code> only supports very limited HTML and can fail when extra tags are sent, <a href="https://core.telegram.org/bots/api#html-style">read more here</a> (or use plaintext/markdown format)</li> | ||||
|                                 <li><code>gets://</code>, <code>posts://</code>, <code>puts://</code>, <code>deletes://</code> for direct API calls (or omit the "<code>s</code>" for non-SSL ie <code>get://</code>)</li> | ||||
|                                   <li>Accepts the <code>{{ '{{token}}' }}</code> placeholders listed below</li> | ||||
|                               </ul> | ||||
|                             </div> | ||||
|                             <div class="notifications-wrapper"> | ||||
|                               <a id="send-test-notification" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Send test notification</a> | ||||
|                               <a id="send-test-notification" class="pure-button button-secondary button-xsmall" >Send test notification</a> | ||||
|                             {% if emailprefix %} | ||||
|                               <a id="add-email-helper" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Add email</a> | ||||
|                               <a id="add-email-helper" class="pure-button button-secondary button-xsmall" >Add email <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="Add an email address"> </a> | ||||
|                             {% endif %} | ||||
|                               <a href="{{url_for('notification_logs')}}" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Notification debug logs</a> | ||||
|                               <a href="{{url_for('notification_logs')}}" class="pure-button button-secondary button-xsmall" >Notification debug logs</a> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                         <div id="notification-customisation" class="pure-control-group"> | ||||
| @@ -103,14 +104,18 @@ | ||||
|                                         <td>The current snapshot value, useful when combined with JSON or CSS filters | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
|                                     <tr> | ||||
|                                         <td><code>{{ '{{triggered_text}}' }}</code></td> | ||||
|                                         <td>Text that tripped the trigger from filters</td> | ||||
|                                     </tr> | ||||
|                                     </tbody> | ||||
|                                 </table> | ||||
|                                 <div class="pure-form-message-inline"> | ||||
|                                     <br> | ||||
|                                     URLs generated by changedetection.io (such as <code>{{ '{{diff_url}}' }}</code>) require the <code>BASE_URL</code> environment variable set.<br/> | ||||
|                                     URLs generated by changedetection.io (such as <code>{{ '{{diff_url}}' }}</code>) require the <code>BASE_URL</code> environment variable set.<br> | ||||
|                                     Your <code>BASE_URL</code> var is currently "{{settings_application['current_base_url']}}" | ||||
| 									<br> | ||||
| 									Warning: Contents of <code>{{ '{{diff}}' }}</code>, <code>{{ '{{diff_removed}}' }}</code>, and <code>{{ '{{diff_added}}' }}</code> depend on how the difference algorithm perceives the change. For example, an addition or removal could be perceived as a change in some cases. <a target="_new" href="https://github.com/dgtlmoon/changedetection.io/wiki/Using-the-%7B%7Bdiff%7D%7D,-%7B%7Bdiff_added%7D%7D,-and-%7B%7Bdiff_removal%7D%7D-notification-tokens">More Here</a> </br> | ||||
| 									Warning: Contents of <code>{{ '{{diff}}' }}</code>, <code>{{ '{{diff_removed}}' }}</code>, and <code>{{ '{{diff_added}}' }}</code> depend on how the difference algorithm perceives the change. For example, an addition or removal could be perceived as a change in some cases. <a target="_new" href="https://github.com/dgtlmoon/changedetection.io/wiki/Using-the-%7B%7Bdiff%7D%7D,-%7B%7Bdiff_added%7D%7D,-and-%7B%7Bdiff_removal%7D%7D-notification-tokens">More Here</a> <br> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|   | ||||
| @@ -1,7 +0,0 @@ | ||||
| {% macro pagination(sorted_watches, total_per_page, current_page) %} | ||||
|   {{ sorted_watches|length }} | ||||
|  | ||||
|   {% for row in sorted_watches|batch(total_per_page, ' ') %} | ||||
|     {{ loop.index}} | ||||
|   {% endfor %} | ||||
| {% endmacro %} | ||||
| @@ -2,35 +2,35 @@ | ||||
| <html lang="en" data-darkmode="{{ get_darkmode_state() }}"> | ||||
|  | ||||
|   <head> | ||||
|     <meta charset="utf-8"/> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | ||||
|     <meta name="description" content="Self hosted website change detection."/> | ||||
|     <meta charset="utf-8" > | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" > | ||||
|     <meta name="description" content="Self hosted website change detection." > | ||||
|     <title>Change Detection{{extra_title}}</title> | ||||
|     <link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag %}- {{active_tag}}{% endif %}" href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}"/> | ||||
|     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}"/> | ||||
|     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}"/> | ||||
|     <link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag %}- {{active_tag}}{% endif %}" href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}" > | ||||
|     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}" > | ||||
|     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}" > | ||||
|     {% if extra_stylesheets %} | ||||
|       {% for m in extra_stylesheets %} | ||||
|         <link rel="stylesheet" href="{{ m }}?ver=1000"/> | ||||
|         <link rel="stylesheet" href="{{ m }}?ver=1000" > | ||||
|       {% endfor %} | ||||
|     {% endif %} | ||||
|  | ||||
|     <link rel="apple-touch-icon" sizes="180x180" href="{{url_for('static_content', group='favicons', filename='apple-touch-icon.png')}}"/> | ||||
|     <link rel="icon" type="image/png" sizes="32x32" href="{{url_for('static_content', group='favicons', filename='favicon-32x32.png')}}"/> | ||||
|     <link rel="icon" type="image/png" sizes="16x16" href="{{url_for('static_content', group='favicons', filename='favicon-16x16.png')}}"/> | ||||
|     <link rel="manifest" href="{{url_for('static_content', group='favicons', filename='site.webmanifest')}}"/> | ||||
|     <link rel="mask-icon" href="{{url_for('static_content', group='favicons', filename='safari-pinned-tab.svg')}}" color="#5bbad5"/> | ||||
|     <link rel="shortcut icon" href="{{url_for('static_content', group='favicons', filename='favicon.ico')}}"/> | ||||
|     <meta name="msapplication-TileColor" content="#da532c"/> | ||||
|     <meta name="msapplication-config" content="favicons/browserconfig.xml"/> | ||||
|     <meta name="theme-color" content="#ffffff"/> | ||||
|     <link rel="apple-touch-icon" sizes="180x180" href="{{url_for('static_content', group='favicons', filename='apple-touch-icon.png')}}"> | ||||
|     <link rel="icon" type="image/png" sizes="32x32" href="{{url_for('static_content', group='favicons', filename='favicon-32x32.png')}}"> | ||||
|     <link rel="icon" type="image/png" sizes="16x16" href="{{url_for('static_content', group='favicons', filename='favicon-16x16.png')}}"> | ||||
|     <link rel="manifest" href="{{url_for('static_content', group='favicons', filename='site.webmanifest')}}"> | ||||
|     <link rel="mask-icon" href="{{url_for('static_content', group='favicons', filename='safari-pinned-tab.svg')}}" color="#5bbad5"> | ||||
|     <link rel="shortcut icon" href="{{url_for('static_content', group='favicons', filename='favicon.ico')}}"> | ||||
|     <meta name="msapplication-TileColor" content="#da532c"> | ||||
|     <meta name="msapplication-config" content="favicons/browserconfig.xml"> | ||||
|     <meta name="theme-color" content="#ffffff"> | ||||
|  | ||||
|     <style> | ||||
|       body::before { | ||||
|         background-image: url({{url_for('static_content', group='images', filename='gradient-border.png') }}); | ||||
|       } | ||||
|     </style> | ||||
|     <script type="text/javascript" src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> | ||||
|     <script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> | ||||
|   </head> | ||||
|  | ||||
|   <body> | ||||
| @@ -137,16 +137,13 @@ | ||||
|           <li class="message"> | ||||
|             Share this link: | ||||
|             <span id="share-link">{{ session['share-link'] }}</span> | ||||
|             <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}"/> | ||||
|             <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}" > | ||||
|           </li> | ||||
|         </ul> | ||||
|       {% endif %} | ||||
|       {% block content %}{% endblock %} | ||||
|     </section> | ||||
|     <script | ||||
|       type="text/javascript" | ||||
|       src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" | ||||
|       defer></script> | ||||
|     <script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script> | ||||
|   </body> | ||||
|  | ||||
| </html> | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
|       action="{{url_for('clear_all_history')}}" | ||||
|       method="POST" | ||||
|     > | ||||
|       <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> | ||||
|       <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|       <fieldset> | ||||
|         <div class="pure-control-group"> | ||||
|           This will remove version history (snapshots) for ALL watches, but keep | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|     const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||
|     {% endif %} | ||||
| </script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> | ||||
|  | ||||
| <div id="settings"> | ||||
|     <h1>Differences</h1> | ||||
| @@ -15,15 +15,15 @@ | ||||
|         <fieldset> | ||||
|  | ||||
|             <label for="diffWords" class="pure-checkbox"> | ||||
|                 <input type="radio" name="diff_type" id="diffWords" value="diffWords"/> Words</label> | ||||
|                 <input type="radio" name="diff_type" id="diffWords" value="diffWords"> Words</label> | ||||
|             <label for="diffLines" class="pure-checkbox"> | ||||
|                 <input type="radio" name="diff_type" id="diffLines" value="diffLines" checked=""/> Lines</label> | ||||
|                 <input type="radio" name="diff_type" id="diffLines" value="diffLines" checked=""> Lines</label> | ||||
|  | ||||
|             <label for="diffChars" class="pure-checkbox"> | ||||
|                 <input type="radio" name="diff_type" id="diffChars" value="diffChars"/> Chars</label> | ||||
|                 <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> | ||||
| @@ -43,7 +43,7 @@ | ||||
|     <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> | ||||
| </div> | ||||
|  | ||||
| @@ -51,7 +51,7 @@ | ||||
|     <a onclick="next_diff();">Jump</a> | ||||
| </div> | ||||
|  | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <div class="tabs"> | ||||
|     <ul> | ||||
|         {% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %} | ||||
| @@ -72,7 +72,7 @@ | ||||
|  | ||||
|     <div class="tab-pane-inner" id="error-screenshot"> | ||||
|         <div class="snapshot-age error">{{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div> | ||||
|         <img id="error-screenshot-img"  style="max-width: 80%" alt="Current error-ing screenshot from most recent request"/> | ||||
|         <img id="error-screenshot-img"  style="max-width: 80%" alt="Current error-ing screenshot from most recent request" > | ||||
|     </div> | ||||
|  | ||||
|      <div class="tab-pane-inner" id="text"> | ||||
| @@ -105,7 +105,7 @@ | ||||
|          {% if is_html_webdriver %} | ||||
|            {% if screenshot %} | ||||
|             <div class="snapshot-age">{{watch_a.snapshot_screenshot_ctime|format_timestamp_timeago}}</div> | ||||
|             <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/> | ||||
|             <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request" > | ||||
|            {% else %} | ||||
|               No screenshot available just yet! Try rechecking the page. | ||||
|            {% endif %} | ||||
| @@ -117,19 +117,19 @@ | ||||
|         <form id="extract-data-form" class="pure-form pure-form-stacked edit-form" | ||||
|               action="{{ url_for('diff_history_page', uuid=uuid) }}#extract" | ||||
|               method="POST"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|  | ||||
|             <p>This tool will extract text data from all of the watch history.</p> | ||||
|  | ||||
|             <div class="pure-control-group"> | ||||
|                 {{ render_field(extract_form.extract_regex) }} | ||||
|                 <span class="pure-form-message-inline"> | ||||
|                     A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract.<br/> | ||||
|                     A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract.<br> | ||||
|  | ||||
|                     <p> | ||||
|                         For example, to extract only the numbers from text ‐</br> | ||||
|                         <strong>Raw text</strong>: <code>Temperature <span style="color: red">5.5</span>°C in Sydney</code></br> | ||||
|                         <strong>RegEx to extract:</strong> <code>Temperature <span style="color: red">([0-9\.]+)</span></code><br/> | ||||
|                         For example, to extract only the numbers from text ‐<br> | ||||
|                         <strong>Raw text</strong>: <code>Temperature <span style="color: red">5.5</span>°C in Sydney</code><br> | ||||
|                         <strong>RegEx to extract:</strong> <code>Temperature <span style="color: red">([0-9\.]+)</span></code><br> | ||||
|                     </p> | ||||
|                     <p> | ||||
|                         <a href="https://RegExr.com/">Be sure to test your RegEx here.</a> | ||||
| @@ -149,9 +149,9 @@ | ||||
| <script> | ||||
|     const newest_version_timestamp = {{newest_version_timestamp}}; | ||||
| </script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff.min.js')}}"></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='diff.min.js')}}"></script> | ||||
|  | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-render.js')}}"></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='diff-render.js')}}"></script> | ||||
|  | ||||
|  | ||||
| {% endblock %} | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_common_fields.jinja' import render_common_settings_form %} | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script> | ||||
|     const notification_base_url="{{url_for('ajax_callback_send_notification_test')}}"; | ||||
|     const watch_visual_selector_data_url="{{url_for('static_content', group='visual_selector_data', filename=uuid)}}"; | ||||
| @@ -14,15 +14,17 @@ | ||||
| {% endif %} | ||||
|  | ||||
|     const browser_steps_config=JSON.parse('{{ browser_steps_config|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)}}"; | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script> | ||||
| {% if playwright_enabled %} | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='browser-steps.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='browser-steps.js')}}" defer></script> | ||||
| {% endif %} | ||||
|  | ||||
| <div class="edit-form monospaced-textarea"> | ||||
| @@ -34,8 +36,15 @@ | ||||
|             {% if playwright_enabled %} | ||||
|             <li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if watch['processor'] == 'text_json_diff' %} | ||||
|             <li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li> | ||||
|             <li class="tab"><a href="#filters-and-triggers">Filters & Triggers</a></li> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if watch['processor'] == 'restock_diff' %} | ||||
|             <li class="tab"><a href="#restock">Restock Detection</a></li> | ||||
|             {% endif %} | ||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||
|         </ul> | ||||
|     </div> | ||||
| @@ -43,14 +52,24 @@ | ||||
|     <div class="box-wrap inner"> | ||||
|         <form class="pure-form pure-form-stacked" | ||||
|               action="{{ url_for('edit_page', uuid=uuid, next = request.args.get('next'), unpause_on_save = request.args.get('unpause_on_save')) }}" method="POST"> | ||||
|              <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|              <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="general"> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }} | ||||
|                         <span class="pure-form-message-inline">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></span><br/> | ||||
|                         <span class="pure-form-message-inline">You can use variables in the URL, perfect for inserting the current date and other logic, <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a></span><br/> | ||||
|                         <span class="pure-form-message-inline">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></span><br> | ||||
|                         <span class="pure-form-message-inline">You can use variables in the URL, perfect for inserting the current date and other logic, <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a></span><br> | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                         {% if watch['processor'] == 'text_json_diff' %} | ||||
|                             Current mode: <strong>Webpage Text/HTML, JSON and PDF changes.</strong><br> | ||||
|                           <a href="{{url_for('edit_page', uuid=uuid)}}?switch_processor=restock_diff" class="pure-button button-xsmall">Switch to re-stock detection mode.</a> | ||||
|                         {% else %} | ||||
|                         Current mode: <strong>Re-stock detection.</strong><br> | ||||
|                           <a href="{{url_for('edit_page', uuid=uuid)}}?switch_processor=text_json_diff" class="pure-button button-xsmall">Switch to Webpage Text/HTML, JSON and PDF changes mode.</a> | ||||
|                         {% endif %} | ||||
|                         </span> | ||||
|  | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.title, class="m-d") }} | ||||
| @@ -106,10 +125,10 @@ | ||||
|                         {{ render_field(form.webdriver_delay) }} | ||||
|                         <div class="pure-form-message-inline"> | ||||
|                             <strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong> | ||||
|                             <br/> | ||||
|                             <br> | ||||
|                             This will wait <i>n</i> seconds before extracting the text. | ||||
|                             {% if using_global_webdriver_wait %} | ||||
|                             <br/><strong>Using the current global default settings</strong> | ||||
|                             <br><strong>Using the current global default settings</strong> | ||||
|                             {% endif %} | ||||
|                         </div> | ||||
|                     </div> | ||||
| @@ -146,7 +165,7 @@ User-Agent: wonderbra 1.0") }} | ||||
|             </div> | ||||
|             {% if playwright_enabled %} | ||||
|             <div class="tab-pane-inner" id="browser-steps"> | ||||
|                 <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}"> | ||||
|                 <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality"> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         <!-- | ||||
| @@ -169,11 +188,12 @@ User-Agent: wonderbra 1.0") }} | ||||
|                                     <span class="loader" > | ||||
|                                         <span id="browsersteps-click-start"> | ||||
|                                             <h2 >Click here to Start</h2> | ||||
|                                             Please allow 10-15 seconds for the browser to connect. | ||||
|                                             <svg style="height: 3.5rem;" version="1.1" viewBox="0 0 32 32"  xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="Layer_1"/><g id="play_x5F_alt"><path d="M16,0C7.164,0,0,7.164,0,16s7.164,16,16,16s16-7.164,16-16S24.836,0,16,0z M10,24V8l16.008,8L10,24z" style="fill: var(--color-grey-400);"/></g></svg><br> | ||||
|                                             Please allow 10-15 seconds for the browser to connect.<br> | ||||
|                                         </span> | ||||
|                                         <div class="spinner"  style="display: none;"></div> | ||||
|                                     </span> | ||||
|                                     <img  class="noselect" id="browsersteps-img" src="" style="max-width: 100%; width: 100%;" /> | ||||
|                                     <img class="noselect" id="browsersteps-img" src="" style="max-width: 100%; width: 100%;" > | ||||
|                                     <canvas  class="noselect" id="browsersteps-selector-canvas" style="max-width: 100%; width: 100%;"></canvas> | ||||
|                                 </div> | ||||
|                             </div> | ||||
| @@ -203,7 +223,7 @@ User-Agent: wonderbra 1.0") }} | ||||
|                     <div class="field-group" id="notification-field-group"> | ||||
|                         {% if has_default_notification_urls %} | ||||
|                         <div class="inline-warning"> | ||||
|                             <img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!"/> | ||||
|                             <img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!" > | ||||
|                             There are <a href="{{ url_for('settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications. | ||||
|                         </div> | ||||
|                         {% endif %} | ||||
| @@ -214,9 +234,10 @@ User-Agent: wonderbra 1.0") }} | ||||
|                 </fieldset> | ||||
|             </div> | ||||
|  | ||||
|             {% if watch['processor'] == 'text_json_diff' %} | ||||
|             <div class="tab-pane-inner" id="filters-and-triggers"> | ||||
|                     <div class="pure-control-group"> | ||||
|                             <strong>Pro-tips:</strong><br/> | ||||
|                             <strong>Pro-tips:</strong><br> | ||||
|                             <ul> | ||||
|                                 <li> | ||||
|                                     Use the preview page to see your filters and triggers highlighted. | ||||
| @@ -226,12 +247,6 @@ User-Agent: wonderbra 1.0") }} | ||||
|                                 </li> | ||||
|                             </ul> | ||||
|                     </div> | ||||
|                     <fieldset> | ||||
|                         <div class="pure-control-group"> | ||||
|                             {{ render_checkbox_field(form.check_unique_lines) }} | ||||
|                             <span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span> | ||||
|                         </div> | ||||
|                     </fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {% set field = render_field(form.include_filters, | ||||
|                             rows=5, | ||||
| @@ -241,9 +256,9 @@ xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                         %} | ||||
|                         {{ field }} | ||||
|                         {% if '/text()' in  field %} | ||||
|                           <span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br/> | ||||
|                           <span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br> | ||||
|                         {% endif %} | ||||
|                         <span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br/> | ||||
|                         <span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br> | ||||
|  | ||||
|                     <ul> | ||||
|                         <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li> | ||||
| @@ -266,40 +281,42 @@ xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                             </li> | ||||
|                     </ul> | ||||
|                     Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a | ||||
|                                 href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br/> | ||||
|                                 href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br> | ||||
|                 </span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                       {{ render_field(form.subtractive_selectors, rows=5, placeholder="header | ||||
|                 <fieldset class="pure-control-group"> | ||||
|                     {{ render_field(form.subtractive_selectors, rows=5, placeholder="header | ||||
| footer | ||||
| nav | ||||
| .stockticker") }} | ||||
|                       <span class="pure-form-message-inline"> | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                           <li> Remove HTML element(s) by CSS selector before text conversion. </li> | ||||
|                           <li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li> | ||||
|                         </ul> | ||||
|                       </span> | ||||
|                     </div> | ||||
|                 <fieldset class="pure-group"> | ||||
|                     {{ render_field(form.ignore_text, rows=5, placeholder="Some text to ignore in a line | ||||
| /some.regex\d{2}/ for case-INsensitive regex | ||||
|                     ") }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                             <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> | ||||
|                             <li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li> | ||||
|                             <li>Changing this will affect the comparison checksum which may trigger an alert</li> | ||||
|                             <li>Use the preview/show current tab to see ignores</li> | ||||
|                         </ul> | ||||
|                 </span> | ||||
|                 </fieldset> | ||||
|                 <div class="text-filtering"> | ||||
|                 <fieldset class="pure-group" id="text-filtering-type-options"> | ||||
|                     <h3>Text filtering</h3> | ||||
|                         Limit trigger/ignore/block/extract to;<br> | ||||
|                         {{ render_checkbox_field(form.filter_text_added) }} | ||||
|                         {{ render_checkbox_field(form.filter_text_replaced) }} | ||||
|                         {{ render_checkbox_field(form.filter_text_removed) }} | ||||
|                     <span class="pure-form-message-inline">Note: Depending on the length and similarity of the text on each line, the algorithm may consider an <strong>addition</strong> instead of <strong>replacement</strong> for example.</span> | ||||
|                     <span class="pure-form-message-inline">So it's always better to select <strong>Added</strong>+<strong>Replaced</strong> when you're interested in new content.</span><br> | ||||
|                     <span class="pure-form-message-inline">When content is merely moved in a list, it will also trigger an <strong>addition</strong>, consider enabling <code><strong>Only trigger when unique lines appear</strong></code></span> | ||||
|                 </fieldset> | ||||
|  | ||||
|             </fieldset> | ||||
|                 <fieldset class="pure-control-group"> | ||||
|                     {{ render_checkbox_field(form.check_unique_lines) }} | ||||
|                     <span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span> | ||||
|                 </fieldset> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.trigger_text, rows=5, placeholder="Some text to wait for in a line | ||||
| /some.regex\d{2}/ for case-INsensitive regex | ||||
|                     ") }} | ||||
| ") }} | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                     <ul> | ||||
|                         <li>Text to wait for before triggering a change/notification, all text and regex are tested <i>case-insensitive</i>.</li> | ||||
| @@ -310,6 +327,21 @@ nav | ||||
|                         </span> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <fieldset class="pure-group"> | ||||
|                     {{ render_field(form.ignore_text, rows=5, placeholder="Some text to ignore in a line | ||||
| /some.regex\d{2}/ for case-INsensitive regex | ||||
| ") }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                             <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> | ||||
|                             <li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li> | ||||
|                             <li>Changing this will affect the comparison checksum which may trigger an alert</li> | ||||
|                             <li>Use the preview/show current tab to see ignores</li> | ||||
|                         </ul> | ||||
|                 </span> | ||||
|  | ||||
|                 </fieldset> | ||||
|  | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.text_should_not_be_present, rows=5, placeholder="For example: Out of stock | ||||
| @@ -334,7 +366,7 @@ Unavailable") }} | ||||
|                         <li>Extracts text in the final output (line by line) after other filters using regular expressions; | ||||
|                             <ul> | ||||
|                                 <li>Regular expression ‐ example <code>/reports.+?2022/i</code></li> | ||||
|                                 <li>Use <code>//(?aiLmsux))</code> type flags (more <a href="https://docs.python.org/3/library/re.html#index-15">information here</a>)<br/></li> | ||||
|                                 <li>Use <code>//(?aiLmsux))</code> type flags (more <a href="https://docs.python.org/3/library/re.html#index-15">information here</a>)<br></li> | ||||
|                                 <li>Keyword example ‐ example <code>Out of stock</code></li> | ||||
|                                 <li>Use groups to extract just that text ‐ example <code>/reports.+?(\d+)/i</code> returns a list of years only</li> | ||||
|                             </ul> | ||||
| @@ -344,16 +376,30 @@ Unavailable") }} | ||||
|                         </span> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 </div> | ||||
|             </div> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if watch['processor'] == 'restock_diff' %} | ||||
|             <div class="tab-pane-inner" id="restock"> | ||||
|                     <fieldset> | ||||
|                         <div class="pure-control-group"> | ||||
|                             {{ render_checkbox_field(form.in_stock_only) }} | ||||
|                             <span class="pure-form-message-inline">Only trigger notifications when page changes from <strong>out of stock</strong> to <strong>back in stock</strong></span> | ||||
|                         </div> | ||||
|                     </fieldset> | ||||
|             </div> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if watch['processor'] == 'text_json_diff' %} | ||||
|             <div class="tab-pane-inner visual-selector-ui" id="visualselector"> | ||||
|                 <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}"> | ||||
|                 <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality"> | ||||
|  | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {% if visualselector_enabled %} | ||||
|                             <span class="pure-form-message-inline"> | ||||
|                                 The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection ‐ after the <i>Browser Steps</i> has completed.<br/><br/> | ||||
|                                 The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection ‐ after the <i>Browser Steps</i> has completed.<br><br> | ||||
|                             </span> | ||||
|  | ||||
|                             <div id="selector-header"> | ||||
| @@ -364,7 +410,7 @@ Unavailable") }} | ||||
|                                 <!-- request the screenshot and get the element offset info ready --> | ||||
|                                 <!-- use img src ready load to know everything is ready to map out --> | ||||
|                                 <!-- @todo: maybe something interesting like a field to select 'elements that contain text... and their parents n' --> | ||||
|                                 <img id="selector-background" /> | ||||
|                                 <img id="selector-background" > | ||||
|                                 <canvas id="selector-canvas"></canvas> | ||||
|                             </div> | ||||
|                             <div id="selector-current-xpath" style="overflow-x: hidden"><strong>Currently:</strong> <span class="text">Loading...</span></div> | ||||
| @@ -378,6 +424,7 @@ Unavailable") }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|             </div> | ||||
|             {% endif %} | ||||
|  | ||||
|             <div id="actions"> | ||||
|                 <div class="pure-control-group"> | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| {% from '_helpers.jinja' import render_field %} | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <div class="edit-form monospaced-textarea"> | ||||
|  | ||||
|     <div class="tabs collapsable"> | ||||
| @@ -12,9 +13,8 @@ | ||||
|  | ||||
|     <div class="box-wrap inner"> | ||||
|         <form class="pure-form pure-form-aligned" action="{{url_for('import_page')}}" method="POST"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|             <div class="tab-pane-inner" id="url-list"> | ||||
|                 <fieldset class="pure-group"> | ||||
|                     <legend> | ||||
|                         Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma | ||||
|                         (,): | ||||
| @@ -23,7 +23,7 @@ | ||||
|                         <br> | ||||
|                         URLs which do not pass validation will stay in the textarea. | ||||
|                     </legend> | ||||
|  | ||||
|                 {{ render_field(form.processor, class="processor") }} | ||||
|  | ||||
|                     <textarea name="urls" class="pure-input-1-2" placeholder="https://" | ||||
|                               style="width: 100%; | ||||
| @@ -31,22 +31,24 @@ | ||||
|                                 white-space: pre; | ||||
|                                 overflow-wrap: normal; | ||||
|                                 overflow-x: scroll;" rows="25">{{ import_url_list_remaining }}</textarea> | ||||
|                 </fieldset> | ||||
|  | ||||
| <div id="quick-watch-processor-type"> | ||||
|  | ||||
|                     </div> | ||||
|  | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="distill-io"> | ||||
|  | ||||
|  | ||||
|                 <fieldset class="pure-group"> | ||||
|  | ||||
|                     <legend> | ||||
|                         Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.</br> | ||||
|                         Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.<br> | ||||
|                         This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, the rest (including <code>schedule</code>) are ignored. | ||||
|                         <br/> | ||||
|                         <br> | ||||
|                         <p> | ||||
|                         How to export? <a href="https://distill.io/docs/web-monitor/how-export-and-import-monitors/">https://distill.io/docs/web-monitor/how-export-and-import-monitors/</a><br/> | ||||
|                         Be sure to set your default fetcher to Chrome if required.</br> | ||||
|                         How to export? <a href="https://distill.io/docs/web-monitor/how-export-and-import-monitors/">https://distill.io/docs/web-monitor/how-export-and-import-monitors/</a><br> | ||||
|                         Be sure to set your default fetcher to Chrome if required.<br> | ||||
|                         </p> | ||||
|                     </legend> | ||||
|  | ||||
| @@ -75,7 +77,7 @@ | ||||
|     ] | ||||
| } | ||||
| " rows="25">{{ original_distill_json }}</textarea> | ||||
|                 </fieldset> | ||||
|  | ||||
|             </div> | ||||
|             <button type="submit" class="pure-button pure-input-1-2 pure-button-primary">Import</button> | ||||
|         </form> | ||||
|   | ||||
| @@ -4,13 +4,13 @@ | ||||
| <div class="login-form"> | ||||
|  <div class="inner"> | ||||
|     <form class="pure-form pure-form-stacked" action="{{url_for('login')}}" method="POST"> | ||||
|         <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|         <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|         <fieldset> | ||||
|             <div class="pure-control-group"> | ||||
|                 <label for="password">Password</label> | ||||
|                 <input type="password" id="password" required="" name="password" value="" | ||||
|                        size="15" autofocus /> | ||||
|                 <input type="hidden" id="email" name="email" value="defaultuser@changedetection.io" /> | ||||
|                 <input type="hidden" id="email" name="email" value="defaultuser@changedetection.io" > | ||||
|             </div> | ||||
|             <div class="pure-control-group"> | ||||
|                 <button type="submit" class="pure-button pure-button-primary">Login</button> | ||||
|   | ||||
| @@ -7,9 +7,9 @@ | ||||
|     const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||
|     {% endif %} | ||||
| </script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> | ||||
|  | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <div class="tabs"> | ||||
|     <ul> | ||||
|         {% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %} | ||||
| @@ -31,7 +31,7 @@ | ||||
|  | ||||
|     <div class="tab-pane-inner" id="error-screenshot"> | ||||
|         <div class="snapshot-age error">{{watch.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div> | ||||
|         <img id="error-screenshot-img"  style="max-width: 80%" alt="Current erroring screenshot from most recent request"/> | ||||
|         <img id="error-screenshot-img"  style="max-width: 80%" alt="Current erroring screenshot from most recent request" > | ||||
|     </div> | ||||
|  | ||||
|     <div class="tab-pane-inner" id="text"> | ||||
| @@ -54,11 +54,11 @@ | ||||
|          <div class="tip"> | ||||
|              For now, Differences are performed on text, not graphically, only the latest screenshot is available. | ||||
|          </div> | ||||
|          </br> | ||||
|          <br> | ||||
|          {% if is_html_webdriver %} | ||||
|            {% if screenshot %} | ||||
|              <div class="snapshot-age">{{watch.snapshot_screenshot_ctime|format_timestamp_timeago}}</div> | ||||
|              <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/> | ||||
|              <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request" > | ||||
|            {% else %} | ||||
|               No screenshot available just yet! Try rechecking the page. | ||||
|            {% endif %} | ||||
| @@ -67,4 +67,4 @@ | ||||
|          {% endif %} | ||||
|      </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -9,10 +9,10 @@ | ||||
|     const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}'); | ||||
| {% endif %} | ||||
| </script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> | ||||
|  | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script> | ||||
| <div class="edit-form"> | ||||
|     <div class="tabs collapsable"> | ||||
|         <ul> | ||||
| @@ -26,7 +26,7 @@ | ||||
|     </div> | ||||
|     <div class="box-wrap inner"> | ||||
|         <form class="pure-form pure-form-stacked settings" action="{{url_for('settings_page')}}" method="POST"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|             <div class="tab-pane-inner" id="general"> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
| @@ -40,7 +40,7 @@ | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class="filter_failure_notification_threshold_attempts") }} | ||||
|                         <span class="pure-form-message-inline">After this many consecutive times that the CSS/xPath filter is missing, send a notification | ||||
|                             <br/> | ||||
|                             <br> | ||||
|                         Set to <strong>0</strong> to disable | ||||
|                         </span> | ||||
|                     </div> | ||||
| @@ -66,7 +66,7 @@ | ||||
|                         {{ render_field(form.application.form.base_url, placeholder="http://yoursite.com:5000/", | ||||
|                         class="m-d") }} | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                             Base URL used for the <code>{{ '{{ base_url }}' }}</code> token in notifications and RSS links.<br/>Default value is the ENV var 'BASE_URL' (Currently "{{settings_application['current_base_url']}}"), | ||||
|                             Base URL used for the <code>{{ '{{ base_url }}' }}</code> token in notifications and RSS links.<br>Default value is the ENV var 'BASE_URL' (Currently "{{settings_application['current_base_url']}}"), | ||||
|                             <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>. | ||||
|                         </span> | ||||
|                     </div> | ||||
| @@ -105,13 +105,13 @@ | ||||
|                         <p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p> | ||||
|                         <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> | ||||
|                     </span> | ||||
|                     <br/> | ||||
|                     <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> | ||||
|                 </div> | ||||
|                 <fieldset class="pure-group" id="webdriver-override-options"> | ||||
|                     <div class="pure-form-message-inline"> | ||||
|                         <strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong> | ||||
|                         <br/> | ||||
|                         <br> | ||||
|                         This will wait <i>n</i> seconds before extracting the text. | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
| @@ -124,14 +124,14 @@ | ||||
|  | ||||
|                     <fieldset class="pure-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.ignore_whitespace) }} | ||||
|                     <span class="pure-form-message-inline">Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.<br/> | ||||
|                     <span class="pure-form-message-inline">Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.<br> | ||||
|                     <i>Note:</i> Changing this will change the status of your existing watches, possibly trigger alerts etc. | ||||
|                     </span> | ||||
|                     </fieldset> | ||||
|                 <fieldset class="pure-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.render_anchor_tag_content) }} | ||||
|                     <span class="pure-form-message-inline">Render anchor tag content, default disabled, when enabled renders links as <code>(link text)[https://somesite.com]</code> | ||||
|                         <br/> | ||||
|                         <br> | ||||
|                     <i>Note:</i> Changing this could affect the content of your existing watches, possibly trigger alerts etc. | ||||
|                     </span> | ||||
|                     </fieldset> | ||||
| @@ -151,7 +151,7 @@ nav | ||||
|                     {{ render_field(form.application.form.global_ignore_text, rows=5, placeholder="Some text to ignore in a line | ||||
| /some.regex\d{2}/ for case-INsensitive regex | ||||
|                     ") }} | ||||
|                     <span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br/> | ||||
|                     <span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br> | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                             <li>Note: This is applied globally in addition to the per-watch rules.</li> | ||||
| @@ -170,8 +170,8 @@ nav | ||||
|  | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.api_access_token_enabled) }} | ||||
|                     <div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header</div><br/> | ||||
|                     <div class="pure-form-message-inline"><br/>API Key <span id="api-key">{{api_key}}</span> | ||||
|                     <div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header</div><br> | ||||
|                     <div class="pure-form-message-inline"><br>API Key <span id="api-key">{{api_key}}</span> | ||||
|                         <span style="display:none;" id="api-key-copy" >copy</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
| @@ -181,7 +181,7 @@ nav | ||||
|                 <p><strong>Tip</strong>: You can connect to websites using <a href="https://brightdata.grsm.io/n0r16zf7eivq">BrightData</a> proxies, their service <strong>WebUnlocker</strong> will solve most CAPTCHAs, whilst their <strong>Residential Proxies</strong> may help to avoid CAPTCHA altogether. </p> | ||||
|                 <p>It may be easier to try <strong>WebUnlocker</strong> first, WebUnlocker also supports country selection.</p> | ||||
|                 <p> | ||||
|                     When you have <a href="https://brightdata.grsm.io/n0r16zf7eivq">registered</a>, enabled the required services, visit the <A href="https://brightdata.com/cp/api_example?">API example page</A>, then select <strong>Python</strong>, set the country you wish to use, then copy+paste the example URL below<br/> | ||||
|                     When you have <a href="https://brightdata.grsm.io/n0r16zf7eivq">registered</a>, enabled the required services, visit the <A href="https://brightdata.com/cp/api_example?">API example page</A>, then select <strong>Python</strong>, set the country you wish to use, then copy+paste the example URL below<br> | ||||
|                     The Proxy URL with BrightData should start with <code>http://brd-customer...</code> | ||||
|                 </p> | ||||
|  | ||||
|   | ||||
| @@ -1,14 +1,13 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_simple_field, render_field %} | ||||
| {% from '_pagination.jinja' import pagination %} | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> | ||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script> | ||||
|  | ||||
| <div class="box"> | ||||
|  | ||||
|     <form class="pure-form" action="{{ url_for('form_quick_watch_add') }}" method="POST" id="new-watch-form"> | ||||
|         <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|         <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|         <fieldset> | ||||
|             <legend>Add a new change detection watch</legend> | ||||
|             <div id="watch-add-wrapper-zone"> | ||||
| @@ -21,21 +20,30 @@ | ||||
|                     {{ render_simple_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }} | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div id="quick-watch-processor-type"> | ||||
|                 {{ render_simple_field(form.processor, title="Edit first then Watch") }} | ||||
|             </div> | ||||
|  | ||||
|         </fieldset> | ||||
|         <span style="color:#eee; font-size: 80%;"><img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" /> Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></a></span> | ||||
|         <span style="color:#eee; font-size: 80%;"><img alt="Create a shareable link" style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" > Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></span> | ||||
|     </form> | ||||
|  | ||||
|     <form class="pure-form" action="{{ url_for('form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|     <div id="checkbox-operations"> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="pause">Pause</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="unpause">UnPause</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="mute">Mute</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="unmute">UnMute</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="recheck">Recheck</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="notification-default">Use default notification</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="background: #dd4242; font-size: 70%" name="op" value="delete">Delete</button> | ||||
|         <button class="pure-button button-secondary button-xsmall"  name="op" value="pause">Pause</button> | ||||
|         <button class="pure-button button-secondary button-xsmall"  name="op" value="unpause">UnPause</button> | ||||
|         <button class="pure-button button-secondary button-xsmall"  name="op" value="mute">Mute</button> | ||||
|         <button class="pure-button button-secondary button-xsmall"  name="op" value="unmute">UnMute</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" name="op" value="recheck">Recheck</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" name="op" value="mark-viewed">Mark viewed</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" name="op" value="notification-default">Use default notification</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="background: #dd4242;" name="op" value="clear-history">Clear/reset history</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="background: #dd4242;" name="op" value="delete">Delete</button> | ||||
|     </div> | ||||
|     {% if watches|length >= pagination.per_page %} | ||||
|         {{ pagination.info }} | ||||
|     {% endif %} | ||||
|     <div> | ||||
|         <a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag }}">All</a> | ||||
|         {% for tag in tags %} | ||||
| @@ -45,18 +53,19 @@ | ||||
|         {% endfor %} | ||||
|     </div> | ||||
|  | ||||
|     {% set sort_order = request.args.get('order', 'asc') == 'asc' %} | ||||
|     {% set sort_attribute = request.args.get('sort', 'last_changed')   %} | ||||
|     {% set sort_order = sort_order or 'asc' %} | ||||
|     {% set sort_attribute = sort_attribute or 'last_changed'  %} | ||||
|     {% set pagination_page = request.args.get('page', 0) %} | ||||
|  | ||||
|     <div id="watch-table-wrapper"> | ||||
|  | ||||
|         <table class="pure-table pure-table-striped watch-table"> | ||||
|             <thead> | ||||
|             <tr> | ||||
|                 <th><input style="vertical-align: middle" type="checkbox" id="check-all"/> #</th> | ||||
|                 <th></th> | ||||
|                 {% set link_order = "desc" if sort_order else "asc" %} | ||||
|                 {% set link_order = "desc" if sort_order  == 'asc' else "asc" %} | ||||
|                 {% set arrow_span = "" %} | ||||
|                 <th><input style="vertical-align: middle" type="checkbox" id="check-all" > <a class="{{ 'active '+link_order if sort_attribute == 'date_created' else 'inactive' }}"  href="{{url_for('index', sort='date_created', order=link_order, tag=active_tag)}}"># <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th></th> | ||||
|                 <th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('index', sort='label', order=link_order, tag=active_tag)}}">Website <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order, tag=active_tag)}}">Last Checked <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th> | ||||
| @@ -65,39 +74,34 @@ | ||||
|             </thead> | ||||
|             <tbody> | ||||
|  | ||||
|             {% set sorted_watches = watches|sort(attribute=sort_attribute, reverse=sort_order) %} | ||||
|             {% for watch in sorted_watches %} | ||||
|  | ||||
|             {# WIP for pagination, disabled for now | ||||
|               {% if not ( loop.index >= 3 and loop.index <=4) %}{% continue %}{% endif %} --> | ||||
|              #} | ||||
|             {% for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))[pagination.skip:pagination.skip+pagination.per_page] %} | ||||
|             <tr id="{{ watch.uuid }}" | ||||
|                 class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} | ||||
|                 class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} processor-{{ watch['processor'] }} | ||||
|                 {% if watch.last_error is defined and watch.last_error != False %}error{% endif %} | ||||
|                 {% if watch.last_notification_error is defined and watch.last_notification_error != False %}error{% endif %} | ||||
|                 {% if watch.paused is defined and watch.paused != False %}paused{% endif %} | ||||
|                 {% if watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}unviewed{% endif %} | ||||
|                 {% if watch.uuid in queued_uuids %}queued{% endif %}"> | ||||
|                 <td class="inline checkbox-uuid" ><input name="uuids"  type="checkbox" value="{{ watch.uuid}} "/> <span>{{ loop.index }}</span></td> | ||||
|                 <td class="inline checkbox-uuid" ><input name="uuids"  type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td> | ||||
|                 <td class="inline watch-controls"> | ||||
|                     {% if not watch.paused %} | ||||
|                     <a class="state-off" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause"/></a> | ||||
|                     <a class="state-off" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a> | ||||
|                     {% else %} | ||||
|                     <a class="state-on" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause"/></a> | ||||
|                     <a class="state-on" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a> | ||||
|                     {% endif %} | ||||
|                     <a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute"/></a> | ||||
|                     <a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a> | ||||
|                 </td> | ||||
|                 <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} | ||||
|                     <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a> | ||||
|                     <a class="link-spread" href="{{url_for('form_share_put_watch', uuid=watch.uuid)}}"><img class="status-icon"  src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" /></a> | ||||
|                     <a class="link-spread" href="{{url_for('form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a> | ||||
|  | ||||
|                     {% if watch.get_fetch_backend == "html_webdriver" | ||||
|                          or (  watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver'  ) | ||||
|                     %} | ||||
|                     <img class="status-icon" src="{{url_for('static_content', group='images', filename='Google-Chrome-icon.png')}}" title="Using a chrome browser" /> | ||||
|                     <img class="status-icon" src="{{url_for('static_content', group='images', filename='Google-Chrome-icon.png')}}" title="Using a chrome browser" > | ||||
|                     {% endif %} | ||||
|  | ||||
|                     {%if watch.is_pdf  %}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" title="Converting PDF to text" />{% endif %} | ||||
|                     {%if watch.is_pdf  %}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" title="Converting PDF to text" >{% endif %} | ||||
|                     {% if watch.last_error is defined and watch.last_error != False %} | ||||
|                     <div class="fetch-error">{{ watch.last_error }} | ||||
|  | ||||
| @@ -113,12 +117,27 @@ | ||||
|                     {% if watch.last_notification_error is defined and watch.last_notification_error != False %} | ||||
|                     <div class="fetch-error notification-error"><a href="{{url_for('notification_logs')}}">{{ watch.last_notification_error }}</a></div> | ||||
|                     {% endif %} | ||||
|                     {% if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data']  %} | ||||
|                     <div class="ldjson-price-track-offer">Embedded price data detected, follow only price data? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div> | ||||
|  | ||||
|                     {% if watch['processor'] == 'text_json_diff'  %} | ||||
|                         {% if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data']  %} | ||||
|                         <div class="ldjson-price-track-offer">Embedded price data detected, follow only price data? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div> | ||||
|                         {% endif %} | ||||
|                         {% if watch['track_ldjson_price_data'] == 'accepted' %} | ||||
|                         <span class="tracking-ldjson-price-data" title="Automatically following embedded price information"><img src="{{url_for('static_content', group='images', filename='price-tag-icon.svg')}}"  class="status-icon price-follow-tag-icon" > Price</span> | ||||
|                         {% endif %} | ||||
|                     {% endif %} | ||||
|                     {% if watch['track_ldjson_price_data'] == 'accepted' %} | ||||
|                     <span class="tracking-ldjson-price-data" title="Automatically following embedded price information"><img src="{{url_for('static_content', group='images', filename='price-tag-icon.svg')}}"  class="status-icon price-follow-tag-icon"/> Price</span> | ||||
|  | ||||
|                     {% if watch['processor'] == 'restock_diff'  %} | ||||
|                     <span class="restock-label {{'in-stock' if watch['in_stock'] else 'not-in-stock' }}" title="detecting restock conditions"> | ||||
|                         <!-- maybe some object watch['processor'][restock_diff] or.. --> | ||||
|                         {% if watch['last_checked'] %} | ||||
|                             {% if watch['in_stock'] %} In stock {% else %} Not in stock {% endif %} | ||||
|                         {% else %} | ||||
|                             Not yet checked | ||||
|                         {% endif %} | ||||
|                     </span> | ||||
|                     {% endif %} | ||||
|  | ||||
|                     {% if not active_tag %} | ||||
|                     <span class="watch-tag-list">{{ watch.tag}}</span> | ||||
|                     {% endif %} | ||||
| @@ -160,10 +179,7 @@ | ||||
|                 <a href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a> | ||||
|             </li> | ||||
|         </ul> | ||||
|         {# WIP for pagination, disabled for now | ||||
|          {{ pagination(sorted_watches,3, pagination_page) }} | ||||
|          #} | ||||
|  | ||||
|         {{ pagination.links }} | ||||
|     </div> | ||||
|     </form> | ||||
| </div> | ||||
|   | ||||
							
								
								
									
										2
									
								
								changedetectionio/tests/restock/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								changedetectionio/tests/restock/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| """Tests for the app.""" | ||||
|  | ||||
							
								
								
									
										3
									
								
								changedetectionio/tests/restock/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changedetectionio/tests/restock/conftest.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| from .. import conftest | ||||
							
								
								
									
										106
									
								
								changedetectionio/tests/restock/test_restock.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								changedetectionio/tests/restock/test_restock.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | ||||
| #!/usr/bin/python3 | ||||
| import os | ||||
| import time | ||||
| from flask import url_for | ||||
| from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
| from changedetectionio.notification import ( | ||||
|     default_notification_body, | ||||
|     default_notification_format, | ||||
|     default_notification_title, | ||||
|     valid_notification_formats, | ||||
| ) | ||||
|  | ||||
|  | ||||
| def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div>price: $10.99</div> | ||||
|      <div id="sametext">Out of stock</div> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
|  | ||||
|  | ||||
| def set_back_in_stock_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div>price: $10.99</div> | ||||
|      <div id="sametext">Available!</div> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
| # Add a site in paused mode, add an invalid filter, we should still have visual selector data ready | ||||
| def test_restock_detection(client, live_server): | ||||
|  | ||||
|     set_original_response() | ||||
|     #assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||
|  | ||||
|     time.sleep(1) | ||||
|     live_server_setup(live_server) | ||||
|     ##################### | ||||
|     notification_url = url_for('test_notification_endpoint', _external=True).replace('http://localhost', 'http://changedet').replace('http', 'json') | ||||
|  | ||||
|  | ||||
|     ##################### | ||||
|     # Set this up for when we remove the notification from the watch, it should fallback with these details | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={"application-notification_urls": notification_url, | ||||
|               "application-notification_title": "fallback-title "+default_notification_title, | ||||
|               "application-notification_body": "fallback-body "+default_notification_body, | ||||
|               "application-notification_format": default_notification_format, | ||||
|               "requests-time_between_check-minutes": 180, | ||||
|               'application-fetch_backend': "html_webdriver"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     # 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 = url_for('test_endpoint', _external=True).replace('http://localhost', 'http://changedet') | ||||
|  | ||||
|  | ||||
|     client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": test_url, "tag": '', 'processor': 'restock_diff'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     # Is it correctly show as NOT in stock? | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'not-in-stock' in res.data | ||||
|  | ||||
|     # Is it correctly shown as in stock | ||||
|     set_back_in_stock_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'not-in-stock' not in res.data | ||||
|  | ||||
|     # We should have a notification | ||||
|     time.sleep(2) | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
|     os.unlink("test-datastore/notification.txt") | ||||
|  | ||||
|     # Default behaviour is to only fire notification when it goes OUT OF STOCK -> IN STOCK | ||||
|     # So here there should be no file, because we go IN STOCK -> OUT OF STOCK | ||||
|     set_original_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     assert not os.path.isfile("test-datastore/notification.txt") | ||||
							
								
								
									
										176
									
								
								changedetectionio/tests/test_add_replace_remove_filter.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								changedetectionio/tests/test_add_replace_remove_filter.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import live_server_setup | ||||
| from changedetectionio import html_tools | ||||
|  | ||||
|  | ||||
| def set_original(excluding=None, add_line=None): | ||||
|     test_return_data = """<html> | ||||
|      <body> | ||||
|      <p>Some initial text</p> | ||||
|      <p>So let's see what happens.</p> | ||||
|      <p>and a new line!</p> | ||||
|      <p>The golden line</p> | ||||
|      <p>A BREAK TO MAKE THE TOP LINE STAY AS "REMOVED" OR IT WILL GET COUNTED AS "CHANGED INTO"</p> | ||||
|      <p>Something irrelevant</p>           | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     if add_line: | ||||
|         c=test_return_data.splitlines() | ||||
|         c.insert(5, add_line) | ||||
|         test_return_data = "\n".join(c) | ||||
|  | ||||
|     if excluding: | ||||
|         output = "" | ||||
|         for i in test_return_data.splitlines(): | ||||
|             if not excluding in i: | ||||
|                 output += f"{i}\n" | ||||
|  | ||||
|         test_return_data = output | ||||
|  | ||||
|     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_check_removed_line_contains_trigger(client, live_server): | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|     set_original() | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"trigger_text": 'The golden line', | ||||
|               "url": test_url, | ||||
|               'fetch_backend': "html_requests", | ||||
|               'filter_text_removed': 'y'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     set_original(excluding='Something irrelevant') | ||||
|  | ||||
|     # A line thats not the trigger should not trigger anything | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     # The trigger line is REMOVED,  this should trigger | ||||
|     set_original(excluding='The golden line') | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|  | ||||
|     # Now add it back, and we should not get a trigger | ||||
|     client.get(url_for("mark_all_viewed"), follow_redirects=True) | ||||
|     set_original(excluding=None) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     # Remove it again, and we should get a trigger | ||||
|     set_original(excluding='The golden line') | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| def test_check_add_line_contains_trigger(client, live_server): | ||||
|  | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|     test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}" | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", | ||||
|               "application-notification_body": 'triggered text was -{{triggered_text}}-', | ||||
|               # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation | ||||
|               "application-notification_urls": test_notification_url, | ||||
|               "application-minutes_between_check": 180, | ||||
|               "application-fetch_backend": "html_requests" | ||||
|               }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b'Settings updated' in res.data | ||||
|  | ||||
|     set_original() | ||||
|     # 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 | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"trigger_text": 'Oh yes please', | ||||
|               "url": test_url, | ||||
|               'fetch_backend': "html_requests", | ||||
|               'filter_text_removed': '', | ||||
|               'filter_text_added': 'y'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     set_original(excluding='Something irrelevant') | ||||
|  | ||||
|     # A line thats not the trigger should not trigger anything | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     # The trigger line is ADDED,  this should trigger | ||||
|     set_original(add_line='<p>Oh yes please</p>') | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     with open("test-datastore/notification.txt", 'r') as f: | ||||
|         response= f.read() | ||||
|         assert '-Oh yes please-' in response | ||||
|  | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
| @@ -11,10 +11,10 @@ import uuid | ||||
| def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div id="sametext">Some text thats the same</div> | ||||
|      <div id="changetext">Some text that will change</div> | ||||
|      </body> | ||||
| @@ -29,10 +29,10 @@ def set_original_response(): | ||||
| def set_modified_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>which has this one new line</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div id="sametext">Some text thats the same</div> | ||||
|      <div id="changetext">Some text that changes</div> | ||||
|      </body> | ||||
|   | ||||
| @@ -7,10 +7,10 @@ from .util import live_server_setup, extract_UUID_from_client, extract_api_key_f | ||||
| def set_response_with_ldjson(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <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> | ||||
|      <script type="application/ld+json"> | ||||
| @@ -61,10 +61,10 @@ def set_response_with_ldjson(): | ||||
| def set_response_without_ldjson(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <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>      | ||||
|      </body> | ||||
| @@ -143,4 +143,4 @@ def test_check_ldjson_price_autodetect(client, live_server): | ||||
|     assert b'ldjson-price-track-offer' not in res.data | ||||
|      | ||||
|     ########################################################################################## | ||||
|     client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|   | ||||
| @@ -11,7 +11,7 @@ sleep_time_for_fetch_thread = 3 | ||||
| # Basic test to check inscriptus is not adding return line chars, basically works etc | ||||
| def test_inscriptus(): | ||||
|     from inscriptis import get_text | ||||
|     html_content = "<html><body>test!<br/>ok man</body></html>" | ||||
|     html_content = "<html><body>test!<br>ok man</body></html>" | ||||
|     stripped_text_from_html = get_text(html_content) | ||||
|     assert stripped_text_from_html == 'test!\nok man' | ||||
|  | ||||
|   | ||||
| @@ -8,10 +8,10 @@ from changedetectionio import html_tools | ||||
| def set_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
| @@ -24,10 +24,10 @@ def set_original_ignore_response(): | ||||
| def set_modified_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some NEW nice initial text</br> | ||||
|      Some NEW nice initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <p>new ignore stuff</p> | ||||
|      <p>out of stock</p> | ||||
|      <p>blah</p> | ||||
| @@ -44,11 +44,11 @@ def set_modified_original_ignore_response(): | ||||
| def set_modified_response_minus_block_text(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some NEW nice initial text</br> | ||||
|      Some NEW nice initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      <p>now on sale $2/p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <p>new ignore stuff</p> | ||||
|      <p>blah</p> | ||||
|      </body> | ||||
| @@ -87,7 +87,10 @@ def test_check_block_changedetection_text_NOT_present(client, live_server): | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"text_should_not_be_present": ignore_text, "url": test_url, 'fetch_backend': "html_requests"}, | ||||
|         data={"text_should_not_be_present": ignore_text, | ||||
|               "url": test_url, | ||||
|               'fetch_backend': "html_requests" | ||||
|               }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
| @@ -129,7 +132,6 @@ def test_check_block_changedetection_text_NOT_present(client, live_server): | ||||
|     set_modified_response_minus_block_text() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|   | ||||
| @@ -12,10 +12,10 @@ def test_setup(live_server): | ||||
| def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div id="sametext">Some text thats the same</div> | ||||
|      <div id="changetext">Some text that will change</div> | ||||
|      </body> | ||||
| @@ -29,10 +29,10 @@ def set_original_response(): | ||||
| def set_modified_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>which has this one new line</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div id="sametext">Some text thats the same</div> | ||||
|      <div id="changetext">Some text that changes</div> | ||||
|      </body> | ||||
|   | ||||
| @@ -25,10 +25,10 @@ def set_original_response(): | ||||
|     </ul> | ||||
|     </nav> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|     <div id="changetext">Some text that will change</div> | ||||
|      </body> | ||||
|     <footer> | ||||
| @@ -54,10 +54,10 @@ def set_modified_response(): | ||||
|     </ul> | ||||
|     </nav> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|     <div id="changetext">Some text that changes</div> | ||||
|      </body> | ||||
|     <footer> | ||||
| @@ -71,7 +71,6 @@ def set_modified_response(): | ||||
|  | ||||
|  | ||||
| def test_element_removal_output(): | ||||
|     from changedetectionio import fetch_site_status | ||||
|     from inscriptis import get_text | ||||
|  | ||||
|     # Check text with sub-parts renders correctly | ||||
| @@ -85,7 +84,7 @@ def test_element_removal_output(): | ||||
|     </ul> | ||||
|     </nav> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>across multiple lines</p> | ||||
|      <div id="changetext">Some text that changes</div> | ||||
|      </body> | ||||
|   | ||||
| @@ -59,6 +59,8 @@ def test_http_error_handler(client, live_server): | ||||
|     _runner_test_http_errors(client, live_server, 404, 'Page not found') | ||||
|     _runner_test_http_errors(client, live_server, 500, '(Internal server Error) received') | ||||
|     _runner_test_http_errors(client, live_server, 400, 'Error - Request returned a HTTP error code 400') | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| # Just to be sure error text is properly handled | ||||
| def test_DNS_errors(client, live_server): | ||||
| @@ -81,4 +83,48 @@ def test_DNS_errors(client, live_server): | ||||
|     assert found_name_resolution_error | ||||
|     # Should always record that we tried | ||||
|     assert bytes("just now".encode('utf-8')) in res.data | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| # Re 1513 | ||||
| def test_low_level_errors_clear_correctly(client, live_server): | ||||
|     #live_server_setup(live_server) | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write("<html><body><div id=here>Hello world</div></body></html>") | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": "https://dfkjasdkfjaidjfsdajfksdajfksdjfDOESNTEXIST.com"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     time.sleep(2) | ||||
|  | ||||
|     # We should see the DNS error | ||||
|     res = client.get(url_for("index")) | ||||
|     found_name_resolution_error = b"Temporary failure in name resolution" in res.data or b"Name or service not known" in res.data | ||||
|     assert found_name_resolution_error | ||||
|  | ||||
|     # Update with what should work | ||||
|     client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={ | ||||
|             "url": test_url, | ||||
|             "fetch_backend": "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     # Now the error should be gone | ||||
|     time.sleep(2) | ||||
|     res = client.get(url_for("index")) | ||||
|     found_name_resolution_error = b"Temporary failure in name resolution" in res.data or b"Name or service not known" in res.data | ||||
|     assert not found_name_resolution_error | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -10,10 +10,10 @@ from ..html_tools import * | ||||
| def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div id="sametext">Some text thats the same</div> | ||||
|      <div class="changetext">Some text that will change</div>      | ||||
|      </body> | ||||
| @@ -28,12 +28,12 @@ def set_original_response(): | ||||
| def set_modified_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>which has this one new line</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div id="sametext">Some text thats the same</div> | ||||
|      <div class="changetext">Some text that did change ( 1000 online <br/> 80 guests<br/>  2000 online )</div> | ||||
|      <div class="changetext">Some text that did change ( 1000 online <br> 80 guests<br>  2000 online )</div> | ||||
|      <div class="changetext">SomeCase insensitive 3456</div> | ||||
|      </body> | ||||
|      </html> | ||||
| @@ -49,8 +49,8 @@ def set_multiline_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|       | ||||
|      <p>Something <br/> | ||||
|         across 6 billion multiple<br/> | ||||
|      <p>Something <br> | ||||
|         across 6 billion multiple<br> | ||||
|         lines | ||||
|      </p> | ||||
|       | ||||
|   | ||||
| @@ -11,10 +11,10 @@ from changedetectionio.model import App | ||||
| def set_response_without_filter(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div id="nope-doesnt-exist">Some text thats the same</div>      | ||||
|      </body> | ||||
|      </html> | ||||
| @@ -28,10 +28,10 @@ def set_response_without_filter(): | ||||
| def set_response_with_filter(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div class="ticket-available">Ticket now on sale!</div>      | ||||
|      </body> | ||||
|      </html> | ||||
|   | ||||
| @@ -8,10 +8,10 @@ from changedetectionio.model import App | ||||
| def set_response_with_filter(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div id="nope-doesnt-exist">Some text thats the same</div>      | ||||
|      </body> | ||||
|      </html> | ||||
| @@ -145,4 +145,4 @@ def test_check_xpath_filter_failure_notification(client, live_server): | ||||
|     time.sleep(1) | ||||
|     run_filter_test(client, '//*[@id="nope-doesnt-exist"]') | ||||
|  | ||||
| # Test that notification is never sent | ||||
| # Test that notification is never sent | ||||
|   | ||||
| @@ -6,11 +6,11 @@ from ..html_tools import html_to_text | ||||
| def test_html_to_text_func(): | ||||
|     test_html = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      <a href="/first_link"> More Text </a> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <a href="second_link.com"> Even More Text </a> | ||||
|      </body> | ||||
|      </html> | ||||
| @@ -21,7 +21,7 @@ def test_html_to_text_func(): | ||||
|  | ||||
|     no_links_text = \ | ||||
|         "Some initial text\n\nWhich is across multiple " \ | ||||
|         "lines\n\nMore Text So let's see what happens. Even More Text" | ||||
|         "lines\n\nMore Text\nSo let's see what happens.\nEven More Text" | ||||
|  | ||||
|     # check that no links are in the extracted text | ||||
|     assert text_content == no_links_text | ||||
| @@ -31,7 +31,7 @@ def test_html_to_text_func(): | ||||
|  | ||||
|     links_text = \ | ||||
|         "Some initial text\n\nWhich is across multiple lines\n\n[ More Text " \ | ||||
|         "](/first_link) So let's see what happens. [ Even More Text ]" \ | ||||
|         "](/first_link)\nSo let's see what happens.\n[ Even More Text ]" \ | ||||
|         "(second_link.com)" | ||||
|  | ||||
|     # check that links are present in the extracted text | ||||
|   | ||||
| @@ -1,7 +1,5 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from . util import live_server_setup | ||||
| from changedetectionio import html_tools | ||||
|  | ||||
| @@ -11,7 +9,7 @@ def test_setup(live_server): | ||||
| # Unit test of the stripper | ||||
| # Always we are dealing in utf-8 | ||||
| def test_strip_regex_text_func(): | ||||
|     from changedetectionio import fetch_site_status | ||||
|     from ..processors import text_json_diff as fetch_site_status | ||||
|  | ||||
|     test_content = """ | ||||
|     but sometimes we want to remove the lines. | ||||
|   | ||||
| @@ -11,7 +11,8 @@ def test_setup(live_server): | ||||
| # Unit test of the stripper | ||||
| # Always we are dealing in utf-8 | ||||
| def test_strip_text_func(): | ||||
|     from changedetectionio import fetch_site_status | ||||
|     from ..processors import text_json_diff as fetch_site_status | ||||
|  | ||||
|  | ||||
|     test_content = """ | ||||
|     Some content | ||||
| @@ -33,10 +34,10 @@ def test_strip_text_func(): | ||||
| def set_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
| @@ -49,10 +50,10 @@ def set_original_ignore_response(): | ||||
| def set_modified_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some NEW nice initial text</br> | ||||
|      Some NEW nice initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <p>new ignore stuff</p> | ||||
|      <p>blah</p> | ||||
|      </body> | ||||
| @@ -68,11 +69,11 @@ def set_modified_original_ignore_response(): | ||||
| def set_modified_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      <P>ZZZZz</P> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
|   | ||||
| @@ -12,10 +12,10 @@ def test_setup(live_server): | ||||
| def set_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <a href="/original_link"> Some More Text </a> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
| @@ -29,10 +29,10 @@ def set_original_ignore_response(): | ||||
| def set_modified_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <a href="/modified_link"> Some More Text </a> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|   | ||||
| @@ -12,10 +12,10 @@ def test_setup(live_server): | ||||
| def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
| @@ -27,10 +27,10 @@ def set_original_response(): | ||||
| def set_some_changed_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines, and a new thing too.</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|   | ||||
| @@ -12,15 +12,15 @@ def test_setup(live_server): | ||||
| def set_original_ignore_response_but_with_whitespace(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p> | ||||
|  | ||||
|  | ||||
|      Which is across multiple lines</p> | ||||
|      <br> | ||||
|      </br> | ||||
|      <br> | ||||
|  | ||||
|          So let's see what happens.  </br> | ||||
|          So let's see what happens.  <br> | ||||
|  | ||||
|  | ||||
|      </body> | ||||
| @@ -34,10 +34,10 @@ def set_original_ignore_response_but_with_whitespace(): | ||||
| def set_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
|   | ||||
| @@ -198,8 +198,8 @@ def test_check_json_without_filter(client, live_server): | ||||
|     ) | ||||
|  | ||||
|     # Should still see '"html": "<b>"' | ||||
|     assert b'"<b>' in res.data | ||||
|     assert res.data.count(b'{\n') >= 2 | ||||
|     assert b'"html": "<b>"' in res.data | ||||
|     assert res.data.count(b'{') >= 2 | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -73,16 +73,12 @@ def test_check_notification(client, live_server): | ||||
|     # We write the PNG to disk, but a JPEG should appear in the notification | ||||
|     # Write the last screenshot png | ||||
|     testimage_png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' | ||||
|     # This one is created when we save the screenshot from the webdriver/playwright session (converted from PNG) | ||||
|     testimage_jpg = '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==' | ||||
|  | ||||
|  | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     datastore = 'test-datastore' | ||||
|     with open(os.path.join(datastore, str(uuid), 'last-screenshot.png'), 'wb') as f: | ||||
|         f.write(base64.b64decode(testimage_png)) | ||||
|     with open(os.path.join(datastore, str(uuid), 'last-screenshot.jpg'), 'wb') as f: | ||||
|         f.write(base64.b64decode(testimage_jpg)) | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
| @@ -162,12 +158,12 @@ def test_check_notification(client, live_server): | ||||
|  | ||||
|     # Check the attachment was added, and that it is a JPEG from the original PNG | ||||
|     notification_submission_object = json.loads(notification_submission) | ||||
|     assert notification_submission_object['attachments'][0]['filename'] == 'last-screenshot.jpg' | ||||
|     # We keep PNG screenshots for now | ||||
|     assert notification_submission_object['attachments'][0]['filename'] == 'last-screenshot.png' | ||||
|     assert len(notification_submission_object['attachments'][0]['base64']) | ||||
|     assert notification_submission_object['attachments'][0]['mimetype'] == 'image/jpeg' | ||||
|     assert notification_submission_object['attachments'][0]['mimetype'] == 'image/png' | ||||
|     jpeg_in_attachment = base64.b64decode(notification_submission_object['attachments'][0]['base64']) | ||||
|     assert b'JFIF' in jpeg_in_attachment | ||||
|     assert testimage_png not in notification_submission | ||||
|  | ||||
|     # Assert that the JPEG is readable (didn't get chewed up somewhere) | ||||
|     from PIL import Image | ||||
|     import io | ||||
| @@ -299,7 +295,10 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server): | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b'Settings updated' in res.data | ||||
|  | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     # Add a watch and trigger a HTTP POST | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|   | ||||
| @@ -8,10 +8,10 @@ from . util import live_server_setup | ||||
| def set_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
| @@ -24,10 +24,10 @@ def set_original_ignore_response(): | ||||
| def set_modified_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some NEW nice initial text</br> | ||||
|      Some NEW nice initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
| @@ -40,12 +40,12 @@ def set_modified_original_ignore_response(): | ||||
| def set_modified_with_trigger_text_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some NEW nice initial text</br> | ||||
|      Some NEW nice initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      <br> | ||||
|      Add to cart | ||||
|      <br/> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
| @@ -142,4 +142,4 @@ def test_trigger_functionality(client, live_server): | ||||
|     res = client.get(url_for("preview_page", uuid="first")) | ||||
|  | ||||
|     # We should be able to see what we triggered on | ||||
|     assert b'<div class="triggered">Add to cart' in res.data | ||||
|     assert b'<div class="triggered">Add to cart' in res.data | ||||
|   | ||||
| @@ -8,10 +8,10 @@ from . util import live_server_setup | ||||
| def set_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
| @@ -72,7 +72,7 @@ def test_trigger_regex_functionality(client, live_server): | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write("regex test123<br/>\nsomething 123") | ||||
|         f.write("regex test123<br>\nsomething 123") | ||||
|  | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
| @@ -81,4 +81,4 @@ def test_trigger_regex_functionality(client, live_server): | ||||
|  | ||||
|     # Cleanup everything | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -8,10 +8,10 @@ from . util import live_server_setup | ||||
| def set_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
|   | ||||
| @@ -94,7 +94,6 @@ def test_unique_lines_functionality(client, live_server): | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|  | ||||
|     # Now set the content which contains the new text and re-ordered existing text | ||||
|     set_modified_with_trigger_text_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|   | ||||
| @@ -12,10 +12,10 @@ def test_setup(live_server): | ||||
| def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <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> | ||||
|      </body> | ||||
| @@ -29,10 +29,10 @@ def set_original_response(): | ||||
| def set_modified_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  THIS CHANGES AND SHOULDNT TRIGGER A CHANGE</br> | ||||
|      <br> | ||||
|      So let's see what happens.  THIS CHANGES AND SHOULDNT TRIGGER A CHANGE<br> | ||||
|      <div class="sametext">Some text thats the same</div> | ||||
|      <div class="changetext">Some new text</div> | ||||
|      </body> | ||||
|   | ||||
| @@ -13,21 +13,37 @@ class TestDiffBuilder(unittest.TestCase): | ||||
|  | ||||
|     def test_expected_diff_output(self): | ||||
|         base_dir = os.path.dirname(__file__) | ||||
|         output = diff.render_diff(previous_file=base_dir + "/test-content/before.txt", newest_file=base_dir + "/test-content/after.txt") | ||||
|         with open(base_dir + "/test-content/before.txt", 'r') as f: | ||||
|             previous_version_file_contents = f.read() | ||||
|  | ||||
|         with open(base_dir + "/test-content/after.txt", 'r') as f: | ||||
|             newest_version_file_contents = f.read() | ||||
|  | ||||
|         output = diff.render_diff(previous_version_file_contents=previous_version_file_contents, | ||||
|                                   newest_version_file_contents=newest_version_file_contents) | ||||
|  | ||||
|         output = output.split("\n") | ||||
|  | ||||
|  | ||||
|         self.assertIn('(changed) ok', output) | ||||
|         self.assertIn('(into) xok', output) | ||||
|         self.assertIn('(into) next-x-ok', output) | ||||
|         self.assertIn('(added) and something new', output) | ||||
|  | ||||
|  | ||||
|         output = diff.render_diff(previous_file=base_dir + "/test-content/before.txt", newest_file=base_dir + "/test-content/after-2.txt") | ||||
|         with open(base_dir + "/test-content/after-2.txt", 'r') as f: | ||||
|             newest_version_file_contents = f.read() | ||||
|         output = diff.render_diff(previous_version_file_contents, newest_version_file_contents) | ||||
|         output = output.split("\n") | ||||
|         self.assertIn('(removed) for having learned computerese,', output) | ||||
|         self.assertIn('(removed) I continue to examine bits, bytes and words', output) | ||||
|          | ||||
|         #diff_removed | ||||
|         output = diff.render_diff(previous_file=base_dir + "/test-content/before.txt", newest_file=base_dir + "/test-content/after.txt", include_equal=False, include_removed=True, include_added=False) | ||||
|         with open(base_dir + "/test-content/before.txt", 'r') as f: | ||||
|             previous_version_file_contents = f.read() | ||||
|  | ||||
|         with open(base_dir + "/test-content/after.txt", 'r') as f: | ||||
|             newest_version_file_contents = f.read() | ||||
|         output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False) | ||||
|         output = output.split("\n") | ||||
|         self.assertIn('(changed) ok', output) | ||||
|         self.assertIn('(into) xok', output) | ||||
| @@ -35,7 +51,9 @@ class TestDiffBuilder(unittest.TestCase): | ||||
|         self.assertNotIn('(added) and something new', output) | ||||
|          | ||||
|         #diff_removed | ||||
|         output = diff.render_diff(previous_file=base_dir + "/test-content/before.txt", newest_file=base_dir + "/test-content/after-2.txt", include_equal=False, include_removed=True, include_added=False) | ||||
|         with open(base_dir + "/test-content/after-2.txt", 'r') as f: | ||||
|             newest_version_file_contents = f.read() | ||||
|         output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False) | ||||
|         output = output.split("\n") | ||||
|         self.assertIn('(removed) for having learned computerese,', output) | ||||
|         self.assertIn('(removed) I continue to examine bits, bytes and words', output) | ||||
|   | ||||
| @@ -9,10 +9,10 @@ def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
|     <head><title>head title</title></head> | ||||
|     <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <span class="foobar-detection" style='display:none'></span> | ||||
|      </body> | ||||
|      </html> | ||||
| @@ -26,10 +26,10 @@ def set_modified_response(): | ||||
|     test_return_data = """<html> | ||||
|     <head><title>modified head title</title></head> | ||||
|     <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>which has this one new line</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
| @@ -43,11 +43,11 @@ def set_more_modified_response(): | ||||
|     test_return_data = """<html> | ||||
|     <head><title>modified head title</title></head> | ||||
|     <body> | ||||
|      Some initial text</br> | ||||
|      Some initial text<br> | ||||
|      <p>which has this one new line</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      Ohh yeah awesome<br/> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      Ohh yeah awesome<br> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|   | ||||
| @@ -4,8 +4,8 @@ import queue | ||||
| import time | ||||
|  | ||||
| from changedetectionio import content_fetcher | ||||
| from changedetectionio import queuedWatchMetaData | ||||
| from changedetectionio.fetch_site_status import FilterNotFoundInResponse | ||||
| from .processors.text_json_diff import FilterNotFoundInResponse | ||||
|  | ||||
|  | ||||
| # A single update worker | ||||
| # | ||||
| @@ -65,22 +65,32 @@ class update_worker(threading.Thread): | ||||
|         if 'notification_urls' in n_object and n_object['notification_urls']: | ||||
|             # HTML needs linebreak, but MarkDown and Text can use a linefeed | ||||
|             if n_object['notification_format'] == 'HTML': | ||||
|                 line_feed_sep = "</br>" | ||||
|                 line_feed_sep = "<br>" | ||||
|             else: | ||||
|                 line_feed_sep = "\n" | ||||
|  | ||||
|             with open(watch_history[dates[-1]], 'rb') as f: | ||||
|                 snapshot_contents = f.read() | ||||
|             # Add text that was triggered | ||||
|             snapshot_contents = watch.get_history_snapshot(dates[-1]) | ||||
|             trigger_text = watch.get('trigger_text', []) | ||||
|             triggered_text = '' | ||||
|  | ||||
|             if len(trigger_text): | ||||
|                 from . import html_tools | ||||
|                 triggered_text = html_tools.get_triggered_text(content=snapshot_contents, trigger_text=trigger_text) | ||||
|                 if triggered_text: | ||||
|                     triggered_text = line_feed_sep.join(triggered_text) | ||||
|  | ||||
|  | ||||
|             n_object.update({ | ||||
|                 'watch_url': watch['url'], | ||||
|                 'current_snapshot': snapshot_contents, | ||||
|                 'diff': diff.render_diff(watch.get_history_snapshot(dates[-2]), watch.get_history_snapshot(dates[-1]), line_feed_sep=line_feed_sep), | ||||
|                 'diff_added': diff.render_diff(watch.get_history_snapshot(dates[-2]), watch.get_history_snapshot(dates[-1]), include_removed=False, line_feed_sep=line_feed_sep), | ||||
|                 'diff_full': diff.render_diff(watch.get_history_snapshot(dates[-2]), watch.get_history_snapshot(dates[-1]), include_equal=True, line_feed_sep=line_feed_sep), | ||||
|                 'diff_removed': diff.render_diff(watch.get_history_snapshot(dates[-2]), watch.get_history_snapshot(dates[-1]), include_added=False, line_feed_sep=line_feed_sep), | ||||
|                 'screenshot': watch.get_screenshot() if watch.get('notification_screenshot') else None, | ||||
|                 'triggered_text': triggered_text, | ||||
|                 'uuid': watch_uuid, | ||||
|                 'screenshot': watch.get_screenshot_as_jpeg() if watch.get('notification_screenshot') else None, | ||||
|                 'current_snapshot': snapshot_contents.decode('utf-8'), | ||||
|                 'diff': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], line_feed_sep=line_feed_sep), | ||||
|                 'diff_added': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], include_removed=False, line_feed_sep=line_feed_sep), | ||||
|                 'diff_removed': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], include_added=False, line_feed_sep=line_feed_sep), | ||||
|                 'diff_full': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], include_equal=True, line_feed_sep=line_feed_sep) | ||||
|                 'watch_url': watch['url'], | ||||
|             }) | ||||
|             logging.info (">> SENDING NOTIFICATION") | ||||
|             self.notification_q.put(n_object) | ||||
| @@ -153,9 +163,8 @@ class update_worker(threading.Thread): | ||||
|                 os.unlink(full_path) | ||||
|  | ||||
|     def run(self): | ||||
|         from changedetectionio import fetch_site_status | ||||
|  | ||||
|         update_handler = fetch_site_status.perform_site_check(datastore=self.datastore) | ||||
|         from .processors import text_json_diff, restock_diff | ||||
|  | ||||
|         while not self.app.config.exit.is_set(): | ||||
|  | ||||
| @@ -172,11 +181,21 @@ class update_worker(threading.Thread): | ||||
|                     changed_detected = False | ||||
|                     contents = b'' | ||||
|                     process_changedetection_results = True | ||||
|                     update_obj= {} | ||||
|                     print("> Processing UUID {} Priority {} URL {}".format(uuid, queued_item_data.priority, self.datastore.data['watching'][uuid]['url'])) | ||||
|                     update_obj = {} | ||||
|                     print("> Processing UUID {} Priority {} URL {}".format(uuid, queued_item_data.priority, | ||||
|                                                                            self.datastore.data['watching'][uuid]['url'])) | ||||
|                     now = time.time() | ||||
|  | ||||
|                     try: | ||||
|                         processor = self.datastore.data['watching'][uuid].get('processor','text_json_diff') | ||||
|  | ||||
|                         # @todo some way to switch by name | ||||
|                         if processor == 'restock_diff': | ||||
|                             update_handler = restock_diff.perform_site_check(datastore=self.datastore) | ||||
|                         else: | ||||
|                             # Used as a default and also by some tests | ||||
|                             update_handler = text_json_diff.perform_site_check(datastore=self.datastore) | ||||
|  | ||||
|                         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. | ||||
| @@ -296,7 +315,8 @@ class update_worker(threading.Thread): | ||||
|                             self.datastore.save_screenshot(watch_uuid=uuid, screenshot=e.screenshot, as_error=True) | ||||
|  | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||
|                                                                            'last_check_status': e.status_code}) | ||||
|                                                                            'last_check_status': e.status_code, | ||||
|                                                                            'has_ldjson_price_data': None}) | ||||
|                         process_changedetection_results = False | ||||
|                     except Exception as e: | ||||
|                         self.app.logger.error("Exception reached processing watch UUID: %s - %s", uuid, str(e)) | ||||
| @@ -314,6 +334,7 @@ class update_worker(threading.Thread): | ||||
|  | ||||
|                         self.cleanup_error_artifacts(uuid) | ||||
|  | ||||
|                     # | ||||
|                     # Different exceptions mean that we may or may not want to bump the snapshot, trigger notifications etc | ||||
|                     if process_changedetection_results: | ||||
|                         try: | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 171 KiB | 
| @@ -2,6 +2,7 @@ eventlet>=0.31.0 | ||||
| feedgen~=0.9 | ||||
| flask-compress | ||||
| flask-login~=0.5 | ||||
| flask-paginate | ||||
| flask_expects_json~=1.7 | ||||
| flask_restful | ||||
| flask_wtf | ||||
| @@ -68,5 +69,5 @@ pillow | ||||
| # playwright is installed at Dockerfile build time because it's not available on all platforms | ||||
|  | ||||
| # Include pytest, so if theres a support issue we can ask them to run these tests on their setup | ||||
| pytest ~=6.2 | ||||
| pytest ~=7.2 | ||||
| pytest-flask ~=1.2 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user