mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 14:47:21 +00:00 
			
		
		
		
	Compare commits
	
		
			106 Commits
		
	
	
		
			updating-j
			...
			simple-mem
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 81004ae09a | ||
|   | ed38012c6e | ||
|   | f07ff9b55e | ||
|   | 1c46914992 | ||
|   | e9c4037178 | ||
|   | 1af342ef64 | ||
|   | e09ee7da97 | ||
|   | 09bc24ff34 | ||
|   | a1d04bb37f | ||
|   | 01f910f840 | ||
|   | bed16009bb | ||
|   | faeed78ffb | ||
|   | 5d9081ccb2 | ||
|   | 2cf1829073 | ||
|   | 526551a205 | ||
|   | ba139e7f3f | ||
|   | 13e343f9da | ||
|   | 13be4623db | ||
|   | 3b19e3d2bf | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ce42f8ea26 | ||
|   | 343e359b39 | ||
|   | ffd160ce0e | ||
|   | d31fc860cc | ||
|   | 90b357f457 | ||
|   | cc147be76e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8ae5ed76ce | ||
|   | a9ed113369 | ||
|   | eacf920b9a | ||
|   | c9af9b6374 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5e65fb606b | ||
|   | 434a1b242e | ||
|   | bce02f9c82 | ||
|   | 76ffc3e891 | ||
|   | c6ee6687b5 | ||
|   | de48892243 | ||
|   | 6aded50aca | ||
|   | b8e279a025 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8041d00e75 | ||
|   | 6a0e14cfce | ||
|   | be91c5425c | ||
|   | 778680d517 | ||
|   | 7e8aa7e3ff | ||
|   | d77f913aa0 | ||
|   | 59cefe58e7 | ||
|   | cfc689e046 | ||
|   | 7b04b52e45 | ||
|   | f49eb4567f | ||
|   | a8959be348 | ||
|   | 05bf3c9a5c | ||
|   | 4293639f51 | ||
|   | f0ed4f64e8 | ||
|   | add2c658b4 | ||
|   | e27f66eb73 | ||
|   | e4504fee49 | ||
|   | 5798581f18 | ||
|   | ef910b86ef | ||
|   | 8d1fb96d18 | ||
|   | 5df5d0fbe7 | ||
|   | 815cba11ca | ||
|   | 3aed4e5af9 | ||
|   | 3618c389c6 | ||
|   | d127214d8f | ||
|   | c0f000b1d1 | ||
|   | ee5294740a | ||
|   | bd6eda696c | ||
|   | 1ba29655f5 | ||
|   | 830a0a3a82 | ||
|   | e110b3ee93 | ||
|   | 3ae9bfa6f9 | ||
|   | 6f3c3b7dfb | ||
|   | 74707909f1 | ||
|   | d4dac23ba1 | ||
|   | f9954f93f3 | ||
|   | 1a43b112dc | ||
|   | db59bf73e1 | ||
|   | 8aac7bccbe | ||
|   | 9449c59fbb | ||
|   | 21f4ba2208 | ||
|   | daef1cd036 | ||
|   | 56b365df40 | ||
|   | 8e5bf91965 | ||
|   | 1ae59551be | ||
|   | a176468fb8 | ||
|   | 8fac593201 | ||
|   | e3b8c0f5af | ||
|   | 514fd7f91e | ||
|   | 38c4768b92 | ||
|   | 6555d99044 | ||
|   | e719dbd19b | ||
|   | b28a8316cc | ||
|   | e609a2d048 | ||
|   | 994d34c776 | ||
|   | de776800e9 | ||
|   | 8b8ed58f20 | ||
|   | 79c6d765de | ||
|   | c6db7fc90e | ||
|   | bc587efae2 | ||
|   | 6ee6be1a5f | ||
|   | c83485094b | ||
|   | 387ce32e6f | ||
|   | 6b9a788d75 | ||
|   | 14e632bc19 | ||
|   | 52c895b2e8 | ||
|   | a62043e086 | ||
|   | 3d390b6ea4 | ||
|   | 301a40ca34 | 
							
								
								
									
										4
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -4,6 +4,10 @@ updates: | ||||
|     directory: / | ||||
|     schedule: | ||||
|       interval: "weekly" | ||||
|     "caronc/apprise": | ||||
|       versioning-strategy: "increase" | ||||
|       schedule: | ||||
|         interval: "daily" | ||||
|     groups: | ||||
|       all: | ||||
|         patterns: | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							| @@ -88,7 +88,7 @@ jobs: | ||||
|       - name: Build and push :dev | ||||
|         id: docker_build | ||||
|         if: ${{ github.ref }} == "refs/heads/master" | ||||
|         uses: docker/build-push-action@v5 | ||||
|         uses: docker/build-push-action@v6 | ||||
|         with: | ||||
|           context: ./ | ||||
|           file: ./Dockerfile | ||||
| @@ -106,7 +106,7 @@ jobs: | ||||
|       - name: Build and push :tag | ||||
|         id: docker_build_tag_release | ||||
|         if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.') | ||||
|         uses: docker/build-push-action@v5 | ||||
|         uses: docker/build-push-action@v6 | ||||
|         with: | ||||
|           context: ./ | ||||
|           file: ./Dockerfile | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
								
							| @@ -51,7 +51,7 @@ jobs: | ||||
|         # Check we can still build under alpine/musl | ||||
|         - name: Test that the docker containers can build (musl via alpine check) | ||||
|           id: docker_build_musl | ||||
|           uses: docker/build-push-action@v5 | ||||
|           uses: docker/build-push-action@v6 | ||||
|           with: | ||||
|             context: ./ | ||||
|             file: ./.github/test/Dockerfile-alpine | ||||
| @@ -59,7 +59,7 @@ jobs: | ||||
|  | ||||
|         - name: Test that the docker containers can build | ||||
|           id: docker_build | ||||
|           uses: docker/build-push-action@v5 | ||||
|           uses: docker/build-push-action@v6 | ||||
|           # https://github.com/docker/build-push-action#customizing | ||||
|           with: | ||||
|             context: ./ | ||||
|   | ||||
							
								
								
									
										213
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										213
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							| @@ -4,17 +4,10 @@ name: ChangeDetection.io App Test | ||||
| on: [push, pull_request] | ||||
|  | ||||
| jobs: | ||||
|   test-application: | ||||
|   lint-code: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|  | ||||
|       # Mainly just for link/flake8 | ||||
|       - name: Set up Python 3.11 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: '3.11' | ||||
|  | ||||
|       - name: Lint with flake8 | ||||
|         run: | | ||||
|           pip3 install flake8 | ||||
| @@ -23,194 +16,24 @@ jobs: | ||||
|           # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide | ||||
|           flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics | ||||
|  | ||||
|       - name: Spin up ancillary testable services | ||||
|         run: | | ||||
|            | ||||
|           docker network create changedet-network | ||||
|            | ||||
|           # Selenium | ||||
|           docker run --network changedet-network -d --hostname selenium  -p 4444:4444 --rm --shm-size="2g"  selenium/standalone-chrome:4 | ||||
|            | ||||
|           # SocketPuppetBrowser + Extra for custom browser test | ||||
|           docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser --hostname sockpuppetbrowser --rm -p 3000:3000 dgtlmoon/sockpuppetbrowser:latest                     | ||||
|           docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser-custom-url --hostname sockpuppetbrowser-custom-url  -p 3001:3000 --rm dgtlmoon/sockpuppetbrowser:latest | ||||
|   test-application-3-10: | ||||
|     needs: lint-code | ||||
|     uses: ./.github/workflows/test-stack-reusable-workflow.yml | ||||
|     with: | ||||
|       python-version: '3.10' | ||||
|  | ||||
|       - name: Build changedetection.io container for testing | ||||
|         run: |          | ||||
|           # Build a changedetection.io container and start testing inside | ||||
|           docker build --build-arg LOGGER_LEVEL=TRACE -t test-changedetectionio . | ||||
|           # Debug info | ||||
|           docker run test-changedetectionio  bash -c 'pip list' | ||||
|  | ||||
|       - name: Spin up ancillary SMTP+Echo message test server | ||||
|         run: | | ||||
|           # Debug SMTP server/echo message back server | ||||
|           docker run --network changedet-network -d -p 11025:11025 -p 11080:11080  --hostname mailserver test-changedetectionio  bash -c 'python changedetectionio/tests/smtp/smtp-test-server.py'  | ||||
|   test-application-3-11: | ||||
|     needs: lint-code | ||||
|     uses: ./.github/workflows/test-stack-reusable-workflow.yml | ||||
|     with: | ||||
|       python-version: '3.11' | ||||
|       skip-pypuppeteer: true | ||||
|  | ||||
|       - name: Show docker container state and other debug info | ||||
|         run: | | ||||
|           set -x | ||||
|           echo "Running processes in docker..." | ||||
|           docker ps | ||||
|   test-application-3-12: | ||||
|     needs: lint-code | ||||
|     uses: ./.github/workflows/test-stack-reusable-workflow.yml | ||||
|     with: | ||||
|       python-version: '3.12' | ||||
|       skip-pypuppeteer: true | ||||
|  | ||||
|       - name: Test built container with Pytest (generally as requests/plaintext fetching) | ||||
|         run: | | ||||
|           # Unit tests | ||||
|           echo "run test with unittest" | ||||
|           docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff' | ||||
|           docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model' | ||||
|            | ||||
|           # All tests | ||||
|           echo "run test with pytest" | ||||
|           # The default pytest logger_level is TRACE | ||||
|           # To change logger_level for pytest(test/conftest.py), | ||||
|           # append the docker option. e.g. '-e LOGGER_LEVEL=DEBUG' | ||||
|           docker run --name test-cdio-basic-tests --network changedet-network  test-changedetectionio  bash -c 'cd changedetectionio && ./run_basic_tests.sh' | ||||
|  | ||||
| # PLAYWRIGHT/NODE-> CDP | ||||
|       - name: Playwright and SocketPuppetBrowser - Specific tests in built container | ||||
|         run: | | ||||
|           # Playwright via Sockpuppetbrowser fetch | ||||
|           # tests/visualselector/test_fetch_data.py will do browser steps   | ||||
|           docker run --rm -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser: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' | ||||
|  | ||||
|       - name: Playwright and SocketPuppetBrowser - Headers and requests | ||||
|         run: |        | ||||
|           # Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers | ||||
|           docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0  --live-server-port=5004 tests/test_request.py' | ||||
|  | ||||
|       - name: Playwright and SocketPuppetBrowser - Restock detection | ||||
|         run: |                             | ||||
|           # restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it | ||||
|           docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser: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' | ||||
|  | ||||
| # STRAIGHT TO CDP | ||||
|       - name: Pyppeteer and SocketPuppetBrowser - Specific tests in built container | ||||
|         run: | | ||||
|           # Playwright via Sockpuppetbrowser fetch | ||||
|           docker run --rm -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" -e "FAST_PUPPETEER_CHROME_FETCHER=True" --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' | ||||
|  | ||||
|       - name: Pyppeteer and SocketPuppetBrowser - Headers and requests checks | ||||
|         run: |        | ||||
|           # Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers | ||||
|           docker run --name "changedet" --hostname changedet --rm  -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0  --live-server-port=5004 tests/test_request.py' | ||||
|  | ||||
|       - name: Pyppeteer and SocketPuppetBrowser - Restock detection | ||||
|         run: |                             | ||||
|           # restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it | ||||
|           docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet"  -e "FAST_PUPPETEER_CHROME_FETCHER=True"  -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser: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' | ||||
|  | ||||
| # SELENIUM | ||||
|       - name: Specific tests in built container for Selenium | ||||
|         run: | | ||||
|           # Selenium fetch | ||||
|           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' | ||||
|  | ||||
|       - name: Specific tests in built container for headers and requests checks with Selenium | ||||
|         run: | | ||||
|           docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0  --live-server-port=5004 tests/test_request.py' | ||||
|  | ||||
| # OTHER STUFF | ||||
|       - name: Test SMTP notification mime types | ||||
|         run: | | ||||
|           # SMTP content types - needs the 'Debug SMTP server/echo message back server' container from above | ||||
|           docker run --rm  --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/smtp/test_notification_smtp.py' | ||||
|  | ||||
|       # @todo Add a test via playwright/puppeteer | ||||
|       # squid with auth is tested in run_proxy_tests.sh -> tests/proxy_list/test_select_custom_proxy.py | ||||
|       - name: Test proxy squid style interaction | ||||
|         run: | | ||||
|           cd changedetectionio | ||||
|           ./run_proxy_tests.sh | ||||
|           cd .. | ||||
|  | ||||
|       - name: Test proxy SOCKS5 style interaction | ||||
|         run: | | ||||
|           cd changedetectionio | ||||
|           ./run_socks_proxy_tests.sh | ||||
|           cd .. | ||||
|  | ||||
|       - name: Test custom browser URL | ||||
|         run: | | ||||
|           cd changedetectionio | ||||
|           ./run_custom_browser_url_tests.sh | ||||
|           cd .. | ||||
|  | ||||
|       - name: Test changedetection.io container starts+runs basically without error | ||||
|         run: | | ||||
|           docker run --name test-changedetectionio -p 5556:5000  -d test-changedetectionio | ||||
|           sleep 3 | ||||
|           # Should return 0 (no error) when grep finds it | ||||
|           curl --retry-connrefused --retry 6  -s http://localhost:5556 |grep -q checkbox-uuid | ||||
|            | ||||
|           # and IPv6 | ||||
|           curl --retry-connrefused --retry 6  -s -g -6 "http://[::1]:5556"|grep -q checkbox-uuid | ||||
|  | ||||
|           # Check whether TRACE log is enabled. | ||||
|           # Also, check whether TRACE is came from STDERR | ||||
|           docker logs test-changedetectionio 2>&1 1>/dev/null | grep 'TRACE log is enabled' || exit 1 | ||||
|           # Check whether DEBUG is came from STDOUT | ||||
|           docker logs test-changedetectionio 2>/dev/null | grep 'DEBUG' || exit 1 | ||||
|  | ||||
|           docker kill test-changedetectionio | ||||
|  | ||||
|       - name: Test changedetection.io SIGTERM and SIGINT signal shutdown | ||||
|         run: | | ||||
|            | ||||
|           echo SIGINT Shutdown request test | ||||
|           docker run --name sig-test -d test-changedetectionio | ||||
|           sleep 3 | ||||
|           echo ">>> Sending SIGINT to sig-test container" | ||||
|           docker kill --signal=SIGINT sig-test | ||||
|           sleep 3 | ||||
|           # invert the check (it should be not 0/not running) | ||||
|           docker ps | ||||
|           # check signal catch(STDERR) log. Because of | ||||
|           # changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level) | ||||
|           docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGINT' || exit 1 | ||||
|           test -z "`docker ps|grep sig-test`" | ||||
|           if [ $? -ne 0 ] | ||||
|           then | ||||
|             echo "Looks like container was running when it shouldnt be" | ||||
|             docker ps | ||||
|             exit 1 | ||||
|           fi | ||||
|            | ||||
|           # @todo - scan the container log to see the right "graceful shutdown" text exists  | ||||
|           docker rm sig-test | ||||
|            | ||||
|           echo SIGTERM Shutdown request test | ||||
|           docker run --name sig-test -d test-changedetectionio | ||||
|           sleep 3 | ||||
|           echo ">>> Sending SIGTERM to sig-test container" | ||||
|           docker kill --signal=SIGTERM sig-test | ||||
|           sleep 3 | ||||
|           # invert the check (it should be not 0/not running) | ||||
|           docker ps | ||||
|           # check signal catch(STDERR) log. Because of | ||||
|           # changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level) | ||||
|           docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGTERM' || exit 1 | ||||
|           test -z "`docker ps|grep sig-test`" | ||||
|           if [ $? -ne 0 ] | ||||
|           then | ||||
|             echo "Looks like container was running when it shouldnt be" | ||||
|             docker ps | ||||
|             exit 1 | ||||
|           fi | ||||
|            | ||||
|           # @todo - scan the container log to see the right "graceful shutdown" text exists            | ||||
|           docker rm sig-test | ||||
|  | ||||
|       - name: Dump container log | ||||
|         if: always() | ||||
|         run: | | ||||
|           mkdir output-logs | ||||
|           docker logs test-cdio-basic-tests > output-logs/test-cdio-basic-tests-stdout.txt | ||||
|           docker logs test-cdio-basic-tests 2> output-logs/test-cdio-basic-tests-stderr.txt | ||||
|  | ||||
|       - name: Store container log | ||||
|         if: always() | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: test-cdio-basic-tests-output | ||||
|           path: output-logs | ||||
|   | ||||
							
								
								
									
										239
									
								
								.github/workflows/test-stack-reusable-workflow.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								.github/workflows/test-stack-reusable-workflow.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,239 @@ | ||||
| name: ChangeDetection.io App Test | ||||
|  | ||||
| on: | ||||
|   workflow_call: | ||||
|     inputs: | ||||
|       python-version: | ||||
|         description: 'Python version to use' | ||||
|         required: true | ||||
|         type: string | ||||
|         default: '3.10' | ||||
|       skip-pypuppeteer: | ||||
|         description: 'Skip PyPuppeteer (not supported in 3.11/3.12)' | ||||
|         required: false | ||||
|         type: boolean | ||||
|         default: false | ||||
|  | ||||
| jobs: | ||||
|   test-application: | ||||
|     runs-on: ubuntu-latest | ||||
|     env: | ||||
|       PYTHON_VERSION: ${{ inputs.python-version }} | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|  | ||||
|       # Mainly just for link/flake8 | ||||
|       - name: Set up Python ${{ env.PYTHON_VERSION }} | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.PYTHON_VERSION }} | ||||
|  | ||||
|       - name: Build changedetection.io container for testing under Python ${{ env.PYTHON_VERSION }} | ||||
|         run: | | ||||
|           echo "---- Building for Python ${{ env.PYTHON_VERSION }} -----" | ||||
|           # Build a changedetection.io container and start testing inside | ||||
|           docker build --build-arg PYTHON_VERSION=${{ env.PYTHON_VERSION }} --build-arg LOGGER_LEVEL=TRACE -t test-changedetectionio . | ||||
|           # Debug info | ||||
|           docker run test-changedetectionio  bash -c 'pip list'          | ||||
|  | ||||
|       - name: We should be Python ${{ env.PYTHON_VERSION }} ... | ||||
|         run: |          | ||||
|           docker run test-changedetectionio  bash -c 'python3 --version' | ||||
|  | ||||
|       - name: Spin up ancillary testable services | ||||
|         run: | | ||||
|            | ||||
|           docker network create changedet-network | ||||
|            | ||||
|           # Selenium | ||||
|           docker run --network changedet-network -d --hostname selenium  -p 4444:4444 --rm --shm-size="2g"  selenium/standalone-chrome:4 | ||||
|            | ||||
|           # SocketPuppetBrowser + Extra for custom browser test | ||||
|           docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser --hostname sockpuppetbrowser --rm -p 3000:3000 dgtlmoon/sockpuppetbrowser:latest                     | ||||
|           docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser-custom-url --hostname sockpuppetbrowser-custom-url  -p 3001:3000 --rm dgtlmoon/sockpuppetbrowser:latest | ||||
|  | ||||
|       - name: Spin up ancillary SMTP+Echo message test server | ||||
|         run: | | ||||
|           # Debug SMTP server/echo message back server | ||||
|           docker run --network changedet-network -d -p 11025:11025 -p 11080:11080  --hostname mailserver test-changedetectionio  bash -c 'pip3 install aiosmtpd && python changedetectionio/tests/smtp/smtp-test-server.py' | ||||
|           docker ps | ||||
|  | ||||
|       - name: Show docker container state and other debug info | ||||
|         run: | | ||||
|           set -x | ||||
|           echo "Running processes in docker..." | ||||
|           docker ps | ||||
|  | ||||
|       - name: Test built container with Pytest (generally as requests/plaintext fetching) | ||||
|         run: | | ||||
|           # Unit tests | ||||
|           echo "run test with unittest" | ||||
|           docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff' | ||||
|           docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model' | ||||
|           docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security' | ||||
|            | ||||
|           # All tests | ||||
|           echo "run test with pytest" | ||||
|           # The default pytest logger_level is TRACE | ||||
|           # To change logger_level for pytest(test/conftest.py), | ||||
|           # append the docker option. e.g. '-e LOGGER_LEVEL=DEBUG' | ||||
|           docker run --name test-cdio-basic-tests --network changedet-network  test-changedetectionio  bash -c 'cd changedetectionio && ./run_basic_tests.sh' | ||||
|  | ||||
| # PLAYWRIGHT/NODE-> CDP | ||||
|       - name: Playwright and SocketPuppetBrowser - Specific tests in built container | ||||
|         run: | | ||||
|           # Playwright via Sockpuppetbrowser fetch | ||||
|           # tests/visualselector/test_fetch_data.py will do browser steps   | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py' | ||||
|  | ||||
|  | ||||
|       - name: Playwright and SocketPuppetBrowser - Headers and requests | ||||
|         run: |        | ||||
|           # Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers | ||||
|           docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0  --live-server-port=5004 tests/test_request.py' | ||||
|  | ||||
|       - name: Playwright and SocketPuppetBrowser - Restock detection | ||||
|         run: |                             | ||||
|           # restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it | ||||
|           docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser: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' | ||||
|  | ||||
| # STRAIGHT TO CDP | ||||
|       - name: Pyppeteer and SocketPuppetBrowser - Specific tests in built container | ||||
|         if: ${{ inputs.skip-pypuppeteer == false }} | ||||
|         run: | | ||||
|           # Playwright via Sockpuppetbrowser fetch  | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py' | ||||
|  | ||||
|       - name: Pyppeteer and SocketPuppetBrowser - Headers and requests checks | ||||
|         if: ${{ inputs.skip-pypuppeteer == false }} | ||||
|         run: | | ||||
|           # Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers | ||||
|           docker run --name "changedet" --hostname changedet --rm  -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0  --live-server-port=5004 tests/test_request.py' | ||||
|  | ||||
|       - name: Pyppeteer and SocketPuppetBrowser - Restock detection | ||||
|         if: ${{ inputs.skip-pypuppeteer == false }} | ||||
|         run: |                             | ||||
|           # restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it | ||||
|           docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet"  -e "FAST_PUPPETEER_CHROME_FETCHER=True"  -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser: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' | ||||
|  | ||||
| # SELENIUM | ||||
|       - name: Specific tests in built container for Selenium | ||||
|         run: | | ||||
|           # Selenium fetch | ||||
|           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' | ||||
|  | ||||
|       - name: Specific tests in built container for headers and requests checks with Selenium | ||||
|         run: | | ||||
|           docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0  --live-server-port=5004 tests/test_request.py' | ||||
|  | ||||
| # OTHER STUFF | ||||
|       - name: Test SMTP notification mime types | ||||
|         run: | | ||||
|           # SMTP content types - needs the 'Debug SMTP server/echo message back server' container from above | ||||
|           # "mailserver" hostname defined above | ||||
|           docker run --rm --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/smtp/test_notification_smtp.py' | ||||
|  | ||||
|       # @todo Add a test via playwright/puppeteer | ||||
|       # squid with auth is tested in run_proxy_tests.sh -> tests/proxy_list/test_select_custom_proxy.py | ||||
|       - name: Test proxy squid style interaction | ||||
|         run: | | ||||
|           cd changedetectionio | ||||
|           ./run_proxy_tests.sh | ||||
|           cd .. | ||||
|  | ||||
|       - name: Test proxy SOCKS5 style interaction | ||||
|         run: | | ||||
|           cd changedetectionio | ||||
|           ./run_socks_proxy_tests.sh | ||||
|           cd .. | ||||
|  | ||||
|       - name: Test custom browser URL | ||||
|         run: | | ||||
|           cd changedetectionio | ||||
|           ./run_custom_browser_url_tests.sh | ||||
|           cd .. | ||||
|  | ||||
|       - name: Test changedetection.io container starts+runs basically without error | ||||
|         run: | | ||||
|           docker run --name test-changedetectionio -p 5556:5000  -d test-changedetectionio | ||||
|           sleep 3 | ||||
|           # Should return 0 (no error) when grep finds it | ||||
|           curl --retry-connrefused --retry 6  -s http://localhost:5556 |grep -q checkbox-uuid | ||||
|            | ||||
|           # and IPv6 | ||||
|           curl --retry-connrefused --retry 6  -s -g -6 "http://[::1]:5556"|grep -q checkbox-uuid | ||||
|  | ||||
|           # Check whether TRACE log is enabled. | ||||
|           # Also, check whether TRACE is came from STDERR | ||||
|           docker logs test-changedetectionio 2>&1 1>/dev/null | grep 'TRACE log is enabled' || exit 1 | ||||
|           # Check whether DEBUG is came from STDOUT | ||||
|           docker logs test-changedetectionio 2>/dev/null | grep 'DEBUG' || exit 1 | ||||
|  | ||||
|           docker kill test-changedetectionio | ||||
|  | ||||
|       - name: Test changedetection.io SIGTERM and SIGINT signal shutdown | ||||
|         run: | | ||||
|            | ||||
|           echo SIGINT Shutdown request test | ||||
|           docker run --name sig-test -d test-changedetectionio | ||||
|           sleep 3 | ||||
|           echo ">>> Sending SIGINT to sig-test container" | ||||
|           docker kill --signal=SIGINT sig-test | ||||
|           sleep 3 | ||||
|           # invert the check (it should be not 0/not running) | ||||
|           docker ps | ||||
|           # check signal catch(STDERR) log. Because of | ||||
|           # changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level) | ||||
|           docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGINT' || exit 1 | ||||
|           test -z "`docker ps|grep sig-test`" | ||||
|           if [ $? -ne 0 ] | ||||
|           then | ||||
|             echo "Looks like container was running when it shouldnt be" | ||||
|             docker ps | ||||
|             exit 1 | ||||
|           fi | ||||
|            | ||||
|           # @todo - scan the container log to see the right "graceful shutdown" text exists  | ||||
|           docker rm sig-test | ||||
|            | ||||
|           echo SIGTERM Shutdown request test | ||||
|           docker run --name sig-test -d test-changedetectionio | ||||
|           sleep 3 | ||||
|           echo ">>> Sending SIGTERM to sig-test container" | ||||
|           docker kill --signal=SIGTERM sig-test | ||||
|           sleep 3 | ||||
|           # invert the check (it should be not 0/not running) | ||||
|           docker ps | ||||
|           # check signal catch(STDERR) log. Because of | ||||
|           # changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level) | ||||
|           docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGTERM' || exit 1 | ||||
|           test -z "`docker ps|grep sig-test`" | ||||
|           if [ $? -ne 0 ] | ||||
|           then | ||||
|             echo "Looks like container was running when it shouldnt be" | ||||
|             docker ps | ||||
|             exit 1 | ||||
|           fi | ||||
|            | ||||
|           # @todo - scan the container log to see the right "graceful shutdown" text exists            | ||||
|           docker rm sig-test | ||||
|  | ||||
|       - name: Dump container log | ||||
|         if: always() | ||||
|         run: | | ||||
|           mkdir output-logs | ||||
|           docker logs test-cdio-basic-tests > output-logs/test-cdio-basic-tests-stdout-${{ env.PYTHON_VERSION }}.txt | ||||
|           docker logs test-cdio-basic-tests 2> output-logs/test-cdio-basic-tests-stderr-${{ env.PYTHON_VERSION }}.txt | ||||
|  | ||||
|       - name: Store container log | ||||
|         if: always() | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }} | ||||
|           path: output-logs | ||||
| @@ -2,7 +2,7 @@ Contributing is always welcome! | ||||
|  | ||||
| I am no professional flask developer, if you know a better way that something can be done, please let me know! | ||||
|  | ||||
| Otherwise, it's always best to PR into the `dev` branch. | ||||
| Otherwise, it's always best to PR into the `master` branch. | ||||
|  | ||||
| Please be sure that all new functionality has a matching test! | ||||
|  | ||||
|   | ||||
							
								
								
									
										10
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -2,7 +2,10 @@ | ||||
|  | ||||
| # @NOTE! I would love to move to 3.11 but it breaks the async handler in changedetectionio/content_fetchers/puppeteer.py | ||||
| #        If you know how to fix it, please do! and test it for both 3.10 and 3.11 | ||||
| FROM python:3.10-slim-bookworm as builder | ||||
|  | ||||
| ARG PYTHON_VERSION=3.11 | ||||
|  | ||||
| FROM python:${PYTHON_VERSION}-slim-bookworm AS builder | ||||
|  | ||||
| # See `cryptography` pin comment in requirements.txt | ||||
| ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1 | ||||
| @@ -23,7 +26,8 @@ WORKDIR /install | ||||
|  | ||||
| COPY requirements.txt /requirements.txt | ||||
|  | ||||
| RUN pip install --target=/dependencies -r /requirements.txt | ||||
| # --extra-index-url https://www.piwheels.org/simple  is for cryptography module to be prebuilt (or rustc etc needs to be installed) | ||||
| RUN pip install --extra-index-url https://www.piwheels.org/simple  --target=/dependencies -r /requirements.txt | ||||
|  | ||||
| # Playwright is an alternative to Selenium | ||||
| # Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing | ||||
| @@ -32,7 +36,7 @@ RUN pip install --target=/dependencies playwright~=1.41.2 \ | ||||
|     || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled." | ||||
|  | ||||
| # Final image stage | ||||
| FROM python:3.10-slim-bookworm | ||||
| FROM python:${PYTHON_VERSION}-slim-bookworm | ||||
|  | ||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
|     libxslt1.1 \ | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| recursive-include changedetectionio/api * | ||||
| recursive-include changedetectionio/blueprint * | ||||
| recursive-include changedetectionio/content_fetchers * | ||||
| recursive-include changedetectionio/model * | ||||
| recursive-include changedetectionio/processors * | ||||
| recursive-include changedetectionio/res * | ||||
| recursive-include changedetectionio/static * | ||||
| recursive-include changedetectionio/templates * | ||||
| recursive-include changedetectionio/tests * | ||||
|   | ||||
							
								
								
									
										16
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								README.md
									
									
									
									
									
								
							| @@ -91,6 +91,14 @@ We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) glob | ||||
|  | ||||
| Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/ | ||||
|  | ||||
| ### We have a Chrome extension! | ||||
|  | ||||
| Easily add the current web page to your changedetection.io tool, simply install the extension and click "Sync" to connect it to your existing changedetection.io install. | ||||
|  | ||||
| [<img src="./docs/chrome-extension-screenshot.png" style="max-width:80%;" alt="Chrome Extension to easily add the current web-page to detect a change."  title="Chrome Extension to easily add the current web-page to detect a change."  />](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop) | ||||
|  | ||||
| [Goto the Chrome Webstore to download the extension.](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop) | ||||
|  | ||||
| ## Installation | ||||
|  | ||||
| ### Docker | ||||
| @@ -249,13 +257,7 @@ Supports managing the website watch list [via our API](https://changedetection.i | ||||
| Do you use changedetection.io to make money? does it save you time or money? Does it make your life easier? less stressful? Remember, we write this software when we should be doing actual paid work, we have to buy food and pay rent just like you. | ||||
|  | ||||
|  | ||||
| Firstly, consider taking out an officially supported [website change detection subscription](https://changedetection.io?src=github) , even if you don't use it, you still get the warm fuzzy feeling of helping out the project. (And who knows, you might just use it!) | ||||
|  | ||||
| Or directly donate an amount PayPal [](https://www.paypal.com/donate/?hosted_button_id=7CP6HR9ZCNDYJ) | ||||
|  | ||||
| Or BTC `1PLFN327GyUarpJd7nVe7Reqg9qHx5frNn` | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/btc-support.png" style="max-width:50%;" alt="Support us!"  /> | ||||
| Consider taking out an officially supported [website change detection subscription](https://changedetection.io?src=github) , even if you don't use it, you still get the warm fuzzy feeling of helping out the project. (And who knows, you might just use it!) | ||||
|  | ||||
| ## Commercial Support | ||||
|  | ||||
|   | ||||
| @@ -2,12 +2,12 @@ | ||||
|  | ||||
| # Read more https://github.com/dgtlmoon/changedetection.io/wiki | ||||
|  | ||||
| __version__ = '0.45.14' | ||||
| __version__ = '0.45.25' | ||||
|  | ||||
| from distutils.util import strtobool | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from json.decoder import JSONDecodeError | ||||
| import os | ||||
| #os.environ['EVENTLET_NO_GREENDNS'] = 'yes' | ||||
| os.environ['EVENTLET_NO_GREENDNS'] = 'yes' | ||||
| import eventlet | ||||
| import eventlet.wsgi | ||||
| import getopt | ||||
| @@ -175,6 +175,7 @@ def main(): | ||||
|     #         proxy_set_header Host "localhost"; | ||||
|     #         proxy_set_header X-Forwarded-Prefix /app; | ||||
|  | ||||
|  | ||||
|     if os.getenv('USE_X_SETTINGS'): | ||||
|         logger.info("USE_X_SETTINGS is ENABLED") | ||||
|         from werkzeug.middleware.proxy_fix import ProxyFix | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import os | ||||
| from distutils.util import strtobool | ||||
| from changedetectionio.strtobool import strtobool | ||||
|  | ||||
| from flask_expects_json import expects_json | ||||
| from changedetectionio import queuedWatchMetaData | ||||
| @@ -170,23 +170,33 @@ class WatchSingleHistory(Resource): | ||||
|             curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history/1677092977 -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" | ||||
|         @apiName Get single snapshot content | ||||
|         @apiGroup Watch History | ||||
|         @apiParam {String} [html]       Optional Set to =1 to return the last HTML (only stores last 2 snapshots, use `latest` as timestamp) | ||||
|         @apiSuccess (200) {String} OK | ||||
|         @apiSuccess (404) {String} ERR Not found | ||||
|         """ | ||||
|         watch = self.datastore.data['watching'].get(uuid) | ||||
|         if not watch: | ||||
|             abort(404, message='No watch exists with the UUID of {}'.format(uuid)) | ||||
|             abort(404, message=f"No watch exists with the UUID of {uuid}") | ||||
|  | ||||
|         if not len(watch.history): | ||||
|             abort(404, message='Watch found but no history exists for the UUID {}'.format(uuid)) | ||||
|             abort(404, message=f"Watch found but no history exists for the UUID {uuid}") | ||||
|  | ||||
|         if timestamp == 'latest': | ||||
|             timestamp = list(watch.history.keys())[-1] | ||||
|  | ||||
|         content = watch.get_history_snapshot(timestamp) | ||||
|         if request.args.get('html'): | ||||
|             content = watch.get_fetched_html(timestamp) | ||||
|             if content: | ||||
|                 response = make_response(content, 200) | ||||
|                 response.mimetype = "text/html" | ||||
|             else: | ||||
|                 response = make_response("No content found", 404) | ||||
|                 response.mimetype = "text/plain" | ||||
|         else: | ||||
|             content = watch.get_history_snapshot(timestamp) | ||||
|             response = make_response(content, 200) | ||||
|             response.mimetype = "text/plain" | ||||
|  | ||||
|         response = make_response(content, 200) | ||||
|         response.mimetype = "text/plain" | ||||
|         return response | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -12,7 +12,7 @@ | ||||
| # | ||||
| # | ||||
|  | ||||
| from distutils.util import strtobool | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from flask import Blueprint, request, make_response | ||||
| import os | ||||
|  | ||||
| @@ -84,7 +84,9 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|         # 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) | ||||
|             proxy=proxy, | ||||
|             start_url=datastore.data['watching'][watch_uuid].get('url') | ||||
|         ) | ||||
|  | ||||
|         # For test | ||||
|         #browsersteps_start_session['browserstepper'].action_goto_url(value="http://example.com?time="+str(time.time())) | ||||
| @@ -167,11 +169,6 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|             step_n = int(request.form.get('step_n')) | ||||
|             is_last_step = strtobool(request.form.get('is_last_step')) | ||||
|  | ||||
|             if step_operation == 'Goto site': | ||||
|                 step_operation = 'goto_url' | ||||
|                 step_optional_value = datastore.data['watching'][uuid].get('url') | ||||
|                 step_selector = None | ||||
|  | ||||
|             # @todo try.. accept.. nice errors not popups.. | ||||
|             try: | ||||
|  | ||||
| @@ -190,8 +187,10 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|             u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url | ||||
|             if is_last_step and u: | ||||
|                 (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) | ||||
|                 watch = datastore.data['watching'].get(uuid) | ||||
|                 if watch: | ||||
|                     watch.save_screenshot(screenshot=screenshot) | ||||
|                     watch.save_xpath_data(data=xpath_data) | ||||
|  | ||||
| #        if not this_session.page: | ||||
| #            cleanup_playwright_session() | ||||
|   | ||||
| @@ -6,6 +6,9 @@ import re | ||||
| from random import randint | ||||
| from loguru import logger | ||||
|  | ||||
| from changedetectionio.content_fetchers.base import manage_user_agent | ||||
| from changedetectionio.safe_jinja import render as jinja_render | ||||
|  | ||||
| # Two flags, tell the JS which of the "Selector" or "Value" field should be enabled in the front end | ||||
| # 0- off, 1- on | ||||
| browser_step_ui_config = {'Choose one': '0 0', | ||||
| @@ -46,6 +49,10 @@ browser_step_ui_config = {'Choose one': '0 0', | ||||
| # ONLY Works in Playwright because we need the fullscreen screenshot | ||||
| class steppable_browser_interface(): | ||||
|     page = None | ||||
|     start_url = None | ||||
|  | ||||
|     def __init__(self, start_url): | ||||
|         self.start_url = start_url | ||||
|  | ||||
|     # Convert and perform "Click Button" for example | ||||
|     def call_action(self, action_name, selector=None, optional_value=None): | ||||
| @@ -62,14 +69,12 @@ class steppable_browser_interface(): | ||||
|         action_handler = getattr(self, "action_" + call_action_name) | ||||
|  | ||||
|         # Support for Jinja2 variables in the value and selector | ||||
|         from jinja2 import Environment | ||||
|         jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||
|  | ||||
|         if selector and ('{%' in selector or '{{' in selector): | ||||
|             selector = str(jinja2_env.from_string(selector).render()) | ||||
|             selector = jinja_render(template_str=selector) | ||||
|  | ||||
|         if optional_value and ('{%' in optional_value or '{{' in optional_value): | ||||
|             optional_value = str(jinja2_env.from_string(optional_value).render()) | ||||
|             optional_value = jinja_render(template_str=optional_value) | ||||
|  | ||||
|         action_handler(selector, optional_value) | ||||
|         self.page.wait_for_timeout(1.5 * 1000) | ||||
| @@ -86,6 +91,10 @@ class steppable_browser_interface(): | ||||
|         logger.debug(f"Time to goto URL {time.time()-now:.2f}s") | ||||
|         return response | ||||
|  | ||||
|     # Incase they request to go back to the start | ||||
|     def action_goto_site(self, selector=None, value=None): | ||||
|         return self.action_goto_url(value=self.start_url) | ||||
|  | ||||
|     def action_click_element_containing_text(self, selector=None, value=''): | ||||
|         if not len(value.strip()): | ||||
|             return | ||||
| @@ -178,6 +187,7 @@ class browsersteps_live_ui(steppable_browser_interface): | ||||
|     stale = False | ||||
|     # bump and kill this if idle after X sec | ||||
|     age_start = 0 | ||||
|     headers = {} | ||||
|  | ||||
|     # use a special driver, maybe locally etc | ||||
|     command_executor = os.getenv( | ||||
| @@ -192,9 +202,11 @@ class browsersteps_live_ui(steppable_browser_interface): | ||||
|  | ||||
|     browser_type = os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').strip('"') | ||||
|  | ||||
|     def __init__(self, playwright_browser, proxy=None): | ||||
|     def __init__(self, playwright_browser, proxy=None, headers=None, start_url=None): | ||||
|         self.headers = headers or {} | ||||
|         self.age_start = time.time() | ||||
|         self.playwright_browser = playwright_browser | ||||
|         self.start_url = start_url | ||||
|         if self.context is None: | ||||
|             self.connect(proxy=proxy) | ||||
|  | ||||
| @@ -206,16 +218,17 @@ class browsersteps_live_ui(steppable_browser_interface): | ||||
|  | ||||
|         # @todo handle multiple contexts, bind a unique id from the browser on each req? | ||||
|         self.context = self.playwright_browser.new_context( | ||||
|             # @todo | ||||
|             #                user_agent=request_headers['User-Agent'] if request_headers.get('User-Agent') else 'Mozilla/5.0', | ||||
|             #               proxy=self.proxy, | ||||
|             # This is needed to enable JavaScript execution on GitHub and others | ||||
|             bypass_csp=True, | ||||
|             # Should never be needed | ||||
|             accept_downloads=False, | ||||
|             proxy=proxy | ||||
|             accept_downloads=False,  # Should never be needed | ||||
|             bypass_csp=True,  # This is needed to enable JavaScript execution on GitHub and others | ||||
|             extra_http_headers=self.headers, | ||||
|             ignore_https_errors=True, | ||||
|             proxy=proxy, | ||||
|             service_workers=os.getenv('PLAYWRIGHT_SERVICE_WORKERS', 'allow'), | ||||
|             # Should be `allow` or `block` - sites like YouTube can transmit large amounts of data via Service Workers | ||||
|             user_agent=manage_user_agent(headers=self.headers), | ||||
|         ) | ||||
|  | ||||
|  | ||||
|         self.page = self.context.new_page() | ||||
|  | ||||
|         # self.page.set_default_navigation_timeout(keep_open) | ||||
| @@ -242,8 +255,9 @@ class browsersteps_live_ui(steppable_browser_interface): | ||||
|  | ||||
|     def get_current_state(self): | ||||
|         """Return the screenshot and interactive elements mapping, generally always called after action_()""" | ||||
|         from pkg_resources import resource_string | ||||
|         xpath_element_js = resource_string(__name__, "../../content_fetchers/res/xpath_element_scraper.js").decode('utf-8') | ||||
|         import importlib.resources | ||||
|         xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text() | ||||
|  | ||||
|         now = time.time() | ||||
|         self.page.wait_for_timeout(1 * 1000) | ||||
|  | ||||
| @@ -274,11 +288,9 @@ class browsersteps_live_ui(steppable_browser_interface): | ||||
|         :param current_include_filters: | ||||
|         :return: | ||||
|         """ | ||||
|  | ||||
|         import importlib.resources | ||||
|         self.page.evaluate("var include_filters=''") | ||||
|         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 | ||||
|         xpath_element_js = resource_string(__name__, "../../content_fetchers/res/xpath_element_scraper.js").decode('utf-8') | ||||
|         xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text() | ||||
|         from changedetectionio.content_fetchers import visualselector_xpath_selectors | ||||
|         xpath_element_js = xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) | ||||
|         xpath_data = self.page.evaluate("async () => {" + xpath_element_js + "}") | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| from playwright.sync_api import PlaywrightContextManager | ||||
| import asyncio | ||||
|  | ||||
| # So playwright wants to run as a context manager, but we do something horrible and hacky | ||||
| # we are holding the session open for as long as possible, then shutting it down, and opening a new one | ||||
|   | ||||
| @@ -31,9 +31,9 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|         import time | ||||
|         from changedetectionio.content_fetchers import exceptions as content_fetcher_exceptions | ||||
|         from changedetectionio.processors import text_json_diff | ||||
|         from changedetectionio.safe_jinja import render as jinja_render | ||||
|  | ||||
|         status = {'status': '', 'length': 0, 'text': ''} | ||||
|         from jinja2 import Environment, BaseLoader | ||||
|  | ||||
|         contents = '' | ||||
|         now = time.time() | ||||
| @@ -64,7 +64,9 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|             status.update({'status': 'OK', 'length': len(contents), 'text': ''}) | ||||
|  | ||||
|         if status.get('text'): | ||||
|             status['text'] = Environment(loader=BaseLoader()).from_string('{{text|e}}').render({'text': status['text']}) | ||||
|             # parse 'text' as text for safety | ||||
|             v = {'text': status['text']} | ||||
|             status['text'] = jinja_render(template_str='{{text|e}}', **v) | ||||
|  | ||||
|         status['time'] = "{:.2f}s".format(time.time() - now) | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
|  | ||||
| from distutils.util import strtobool | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from flask import Blueprint, flash, redirect, url_for | ||||
| from flask_login import login_required | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
|   | ||||
| @@ -12,9 +12,15 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|         from .form import SingleTag | ||||
|         add_form = SingleTag(request.form) | ||||
|         sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title']) | ||||
|  | ||||
|         from collections import Counter | ||||
|  | ||||
|         tag_count = Counter(tag for watch in datastore.data['watching'].values() if watch.get('tags') for tag in watch['tags']) | ||||
|  | ||||
|         output = render_template("groups-overview.html", | ||||
|                                  form=add_form, | ||||
|                                  available_tags=sorted_tags, | ||||
|                                  form=add_form, | ||||
|                                  tag_count=tag_count | ||||
|                                  ) | ||||
|  | ||||
|         return output | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_common_fields.jinja' import render_common_settings_form %} | ||||
| {% from '_helpers.html' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_common_fields.html' import render_common_settings_form %} | ||||
| <script> | ||||
|     const notification_base_url="{{url_for('ajax_callback_send_notification_test', watch_uuid=uuid)}}"; | ||||
|     const notification_base_url="{{url_for('ajax_callback_send_notification_test', mode="group-settings")}}"; | ||||
| </script> | ||||
|  | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| @@ -63,7 +63,7 @@ xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                             <ul> | ||||
|                                 <li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required,  <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li> | ||||
|                                 {% if jq_support %} | ||||
|                                 <li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>.</li> | ||||
|                                 <li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>. Prefix <code>jqraw:</code> outputs the results as text instead of a JSON list.</li> | ||||
|                                 {% else %} | ||||
|                                 <li>jq support not installed</li> | ||||
|                                 {% endif %} | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_simple_field, render_field %} | ||||
| {% from '_helpers.html' import render_simple_field, render_field %} | ||||
| <script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> | ||||
|  | ||||
| <div class="box"> | ||||
| @@ -27,6 +27,7 @@ | ||||
|             <thead> | ||||
|             <tr> | ||||
|                 <th></th> | ||||
|                 <th># Watches</th> | ||||
|                 <th>Tag / Label name</th> | ||||
|                 <th></th> | ||||
|             </tr> | ||||
| @@ -45,7 +46,8 @@ | ||||
|                 <td class="watch-controls"> | ||||
|                     <a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}"><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">{{tag.title}}</td> | ||||
|                 <td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td> | ||||
|                 <td class="title-col inline"> <a href="{{url_for('index', tag=uuid) }}">{{ tag.title }}</a></td> | ||||
|                 <td> | ||||
|                     <a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">Edit</a>  | ||||
|                     <a class="pure-button pure-button-primary" href="{{ url_for('tags.delete', uuid=uuid) }}" title="Deletes and removes tag">Delete</a> | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import sys | ||||
| from distutils.util import strtobool | ||||
|  | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from loguru import logger | ||||
| from changedetectionio.content_fetchers.exceptions import BrowserStepsStepException | ||||
| import os | ||||
|  | ||||
| @@ -29,10 +29,15 @@ def available_fetchers(): | ||||
| # rather than site-specific. | ||||
| use_playwright_as_chrome_fetcher = os.getenv('PLAYWRIGHT_DRIVER_URL', False) | ||||
| if use_playwright_as_chrome_fetcher: | ||||
|     # @note - For now, browser steps always uses playwright | ||||
|     if not strtobool(os.getenv('FAST_PUPPETEER_CHROME_FETCHER', 'False')): | ||||
|         logger.debug('Using Playwright library as fetcher') | ||||
|         from .playwright import fetcher as html_webdriver | ||||
|     else: | ||||
|         logger.debug('Using direct Python Puppeteer library as fetcher') | ||||
|         from .puppeteer import fetcher as html_webdriver | ||||
|  | ||||
| else: | ||||
|     logger.debug("Falling back to selenium as fetcher") | ||||
|     from .webdriver_selenium import fetcher as html_webdriver | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,40 @@ from loguru import logger | ||||
| from changedetectionio.content_fetchers import BrowserStepsStepException | ||||
|  | ||||
|  | ||||
| def manage_user_agent(headers, current_ua=''): | ||||
|     """ | ||||
|     Basic setting of user-agent | ||||
|  | ||||
|     NOTE!!!!!! The service that does the actual Chrome fetching should handle any anti-robot techniques | ||||
|     THERE ARE MANY WAYS THAT IT CAN BE DETECTED AS A ROBOT!! | ||||
|     This does not take care of | ||||
|     - Scraping of 'navigator' (platform, productSub, vendor, oscpu etc etc) browser object (navigator.appVersion) etc | ||||
|     - TCP/IP fingerprint JA3 etc | ||||
|     - Graphic rendering fingerprinting | ||||
|     - Your IP being obviously in a pool of bad actors | ||||
|     - Too many requests | ||||
|     - Scraping of SCH-UA browser replies (thanks google!!) | ||||
|     - Scraping of ServiceWorker, new window calls etc | ||||
|  | ||||
|     See https://filipvitas.medium.com/how-to-set-user-agent-header-with-puppeteer-js-and-not-fail-28c7a02165da | ||||
|     Puppeteer requests https://github.com/dgtlmoon/pyppeteerstealth | ||||
|  | ||||
|     :param page: | ||||
|     :param headers: | ||||
|     :return: | ||||
|     """ | ||||
|     # Ask it what the user agent is, if its obviously ChromeHeadless, switch it to the default | ||||
|     ua_in_custom_headers = headers.get('User-Agent') | ||||
|     if ua_in_custom_headers: | ||||
|         return ua_in_custom_headers | ||||
|  | ||||
|     if not ua_in_custom_headers and current_ua: | ||||
|         current_ua = current_ua.replace('HeadlessChrome', 'Chrome') | ||||
|         return current_ua | ||||
|  | ||||
|     return None | ||||
|  | ||||
|  | ||||
| class Fetcher(): | ||||
|     browser_connection_is_custom = None | ||||
|     browser_connection_url = None | ||||
| @@ -30,10 +64,9 @@ class Fetcher(): | ||||
|     render_extract_delay = 0 | ||||
|  | ||||
|     def __init__(self): | ||||
|         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') | ||||
|         import importlib.resources | ||||
|         self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text() | ||||
|         self.instock_data_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text() | ||||
|  | ||||
|     @abstractmethod | ||||
|     def get_error(self): | ||||
| @@ -78,24 +111,26 @@ class Fetcher(): | ||||
|  | ||||
|     def browser_steps_get_valid_steps(self): | ||||
|         if self.browser_steps is not None and len(self.browser_steps): | ||||
|             valid_steps = filter( | ||||
|                 lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'), | ||||
|                 self.browser_steps) | ||||
|             valid_steps = list(filter( | ||||
|                 lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one'), | ||||
|                 self.browser_steps)) | ||||
|  | ||||
|             # Just incase they selected Goto site by accident with older JS | ||||
|             if valid_steps and valid_steps[0]['operation'] == 'Goto site': | ||||
|                 del(valid_steps[0]) | ||||
|  | ||||
|             return valid_steps | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     def iterate_browser_steps(self): | ||||
|     def iterate_browser_steps(self, start_url=None): | ||||
|         from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface | ||||
|         from playwright._impl._errors import TimeoutError, Error | ||||
|         from jinja2 import Environment | ||||
|         jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||
|  | ||||
|         from changedetectionio.safe_jinja import render as jinja_render | ||||
|         step_n = 0 | ||||
|  | ||||
|         if self.browser_steps is not None and len(self.browser_steps): | ||||
|             interface = steppable_browser_interface() | ||||
|             interface = steppable_browser_interface(start_url=start_url) | ||||
|             interface.page = self.page | ||||
|             valid_steps = self.browser_steps_get_valid_steps() | ||||
|  | ||||
| @@ -109,9 +144,9 @@ class Fetcher(): | ||||
|                     selector = step['selector'] | ||||
|                     # Support for jinja2 template in step values, with date module added | ||||
|                     if '{%' in step['optional_value'] or '{{' in step['optional_value']: | ||||
|                         optional_value = str(jinja2_env.from_string(step['optional_value']).render()) | ||||
|                         optional_value = jinja_render(template_str=step['optional_value']) | ||||
|                     if '{%' in step['selector'] or '{{' in step['selector']: | ||||
|                         selector = str(jinja2_env.from_string(step['selector']).render()) | ||||
|                         selector = jinja_render(template_str=step['selector']) | ||||
|  | ||||
|                     getattr(interface, "call_action")(action_name=step['operation'], | ||||
|                                                       selector=selector, | ||||
|   | ||||
| @@ -87,11 +87,12 @@ class ScreenshotUnavailable(Exception): | ||||
|  | ||||
|  | ||||
| class ReplyWithContentButNoText(Exception): | ||||
|     def __init__(self, status_code, url, screenshot=None, has_filters=False, html_content=''): | ||||
|     def __init__(self, status_code, url, screenshot=None, has_filters=False, html_content='', xpath_data=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         self.has_filters = has_filters | ||||
|         self.html_content = html_content | ||||
|         self.xpath_data = xpath_data | ||||
|         return | ||||
|   | ||||
| @@ -3,7 +3,8 @@ import os | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| from loguru import logger | ||||
| from changedetectionio.content_fetchers.base import Fetcher | ||||
|  | ||||
| from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent | ||||
| from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable | ||||
|  | ||||
| class fetcher(Fetcher): | ||||
| @@ -102,26 +103,23 @@ class fetcher(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={k.lower(): v for k, v in request_headers.items()}.get('user-agent', None), | ||||
|                 accept_downloads=False,  # Should never be needed | ||||
|                 bypass_csp=True,  # This is needed to enable JavaScript execution on GitHub and others | ||||
|                 extra_http_headers=request_headers, | ||||
|                 ignore_https_errors=True, | ||||
|                 proxy=self.proxy, | ||||
|                 # This is needed to enable JavaScript execution on GitHub and others | ||||
|                 bypass_csp=True, | ||||
|                 # Should be `allow` or `block` - sites like YouTube can transmit large amounts of data via Service Workers | ||||
|                 service_workers=os.getenv('PLAYWRIGHT_SERVICE_WORKERS', 'allow'), | ||||
|                 # Should never be needed | ||||
|                 accept_downloads=False | ||||
|                 service_workers=os.getenv('PLAYWRIGHT_SERVICE_WORKERS', 'allow'), # Should be `allow` or `block` - sites like YouTube can transmit large amounts of data via Service Workers | ||||
|                 user_agent=manage_user_agent(headers=request_headers), | ||||
|             ) | ||||
|  | ||||
|             self.page = context.new_page() | ||||
|             if len(request_headers): | ||||
|                 context.set_extra_http_headers(request_headers) | ||||
|  | ||||
|             # Listen for all console events and handle errors | ||||
|             self.page.on("console", lambda msg: print(f"Playwright console: Watch URL: {url} {msg.type}: {msg.text} {msg.args}")) | ||||
|  | ||||
|             # Re-use as much code from browser steps as possible so its the same | ||||
|             from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface | ||||
|             browsersteps_interface = steppable_browser_interface() | ||||
|             browsersteps_interface = steppable_browser_interface(start_url=url) | ||||
|             browsersteps_interface.page = self.page | ||||
|  | ||||
|             response = browsersteps_interface.action_goto_url(value=url) | ||||
| @@ -174,7 +172,7 @@ class fetcher(Fetcher): | ||||
|  | ||||
|             # Run Browser Steps here | ||||
|             if self.browser_steps_get_valid_steps(): | ||||
|                 self.iterate_browser_steps() | ||||
|                 self.iterate_browser_steps(start_url=url) | ||||
|  | ||||
|             self.page.wait_for_timeout(extra_wait * 1000) | ||||
|  | ||||
|   | ||||
| @@ -5,9 +5,9 @@ import websockets.exceptions | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| from loguru import logger | ||||
| from changedetectionio.content_fetchers.base import Fetcher | ||||
| from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, BrowserConnectError | ||||
|  | ||||
| from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent | ||||
| from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, BrowserConnectError | ||||
|  | ||||
| class fetcher(Fetcher): | ||||
|     fetcher_description = "Puppeteer/direct {}/Javascript".format( | ||||
| @@ -92,18 +92,42 @@ class fetcher(Fetcher): | ||||
|                                                        ignoreHTTPSErrors=True | ||||
|                                                        ) | ||||
|         except websockets.exceptions.InvalidStatusCode as e: | ||||
|             raise BrowserConnectError(msg=f"Error while trying to connect the browser, Code {e.status_code} (check your access)") | ||||
|             raise BrowserConnectError(msg=f"Error while trying to connect the browser, Code {e.status_code} (check your access, whitelist IP, password etc)") | ||||
|         except websockets.exceptions.InvalidURI: | ||||
|             raise BrowserConnectError(msg=f"Error connecting to the browser, check your browser connection address (should be ws:// or wss://") | ||||
|         except Exception as e: | ||||
|             raise BrowserConnectError(msg=f"Error connecting to the browser {str(e)}") | ||||
|  | ||||
|         # Better is to launch chrome with the URL as arg | ||||
|         # non-headless - newPage() will launch an extra tab/window, .browser should already contain 1 page/tab | ||||
|         # headless - ask a new page | ||||
|         self.page = (pages := await browser.pages) and len(pages) or await browser.newPage() | ||||
|  | ||||
|         try: | ||||
|             from pyppeteerstealth import inject_evasions_into_page | ||||
|         except ImportError: | ||||
|             logger.debug("pyppeteerstealth module not available, skipping") | ||||
|             pass | ||||
|         else: | ||||
|             self.page = await browser.newPage() | ||||
|             # I tried hooking events via self.page.on(Events.Page.DOMContentLoaded, inject_evasions_requiring_obj_to_page) | ||||
|             # But I could never get it to fire reliably, so we just inject it straight after | ||||
|             await inject_evasions_into_page(self.page) | ||||
|  | ||||
|         # This user agent is similar to what was used when tweaking the evasions in inject_evasions_into_page(..) | ||||
|         user_agent = None | ||||
|         if request_headers and request_headers.get('User-Agent'): | ||||
|             # Request_headers should now be CaaseInsensitiveDict | ||||
|             # Remove it so it's not sent again with headers after | ||||
|             user_agent = request_headers.pop('User-Agent').strip() | ||||
|             await self.page.setUserAgent(user_agent) | ||||
|  | ||||
|         if not user_agent: | ||||
|             # Attempt to strip 'HeadlessChrome' etc | ||||
|             await self.page.setUserAgent(manage_user_agent(headers=request_headers, current_ua=await self.page.evaluate('navigator.userAgent'))) | ||||
|  | ||||
|         await self.page.setBypassCSP(True) | ||||
|         if request_headers: | ||||
|             await self.page.setExtraHTTPHeaders(request_headers) | ||||
|             # @todo check user-agent worked | ||||
|  | ||||
|         # SOCKS5 with authentication is not supported (yet) | ||||
|         # https://github.com/microsoft/playwright/issues/10567 | ||||
| @@ -212,8 +236,12 @@ class fetcher(Fetcher): | ||||
|                 logger.error('ERROR: Failed to get viewport-only reduced screenshot :(') | ||||
|                 pass | ||||
|         finally: | ||||
|             # It's good to log here in the case that the browser crashes on shutting down but we still get the data we need | ||||
|             logger.success(f"Fetching '{url}' complete, closing page") | ||||
|             await self.page.close() | ||||
|             logger.success(f"Fetching '{url}' complete, closing browser") | ||||
|             await browser.close() | ||||
|         logger.success(f"Fetching '{url}' complete, exiting puppeteer fetch.") | ||||
|  | ||||
|     async def main(self, **kwargs): | ||||
|         await self.fetch_page(**kwargs) | ||||
|   | ||||
| @@ -30,11 +30,6 @@ class fetcher(Fetcher): | ||||
|         if self.browser_steps_get_valid_steps(): | ||||
|             raise BrowserStepsInUnsupportedFetcher(url=url) | ||||
|  | ||||
|         # Make requests use a more modern looking user-agent | ||||
|         if not {k.lower(): v for k, v in request_headers.items()}.get('user-agent', None): | ||||
|             request_headers['User-Agent'] = os.getenv("DEFAULT_SETTINGS_HEADERS_USERAGENT", | ||||
|                                                       'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36') | ||||
|  | ||||
|         proxies = {} | ||||
|  | ||||
|         # Allows override the proxy on a per-request basis | ||||
|   | ||||
							
								
								
									
										1
									
								
								changedetectionio/content_fetchers/res/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								changedetectionio/content_fetchers/res/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| # resources for browser injection/scraping | ||||
| @@ -10,15 +10,16 @@ function isItemInStock() { | ||||
|     const outOfStockTexts = [ | ||||
|         ' أخبرني عندما يتوفر', | ||||
|         '0 in stock', | ||||
|         'actuellement indisponible',         | ||||
|         'actuellement indisponible', | ||||
|         'agotado', | ||||
|         'article épuisé', | ||||
|         'artikel zurzeit vergriffen', | ||||
|         'as soon as stock is available', | ||||
|         'ausverkauft', // sold out | ||||
|         'available for back order', | ||||
|         'back-order or out of stock', | ||||
|         'awaiting stock', | ||||
|         'back in stock soon', | ||||
|         'back-order or out of stock', | ||||
|         'backordered', | ||||
|         'benachrichtigt mich', // notify me | ||||
|         'brak na stanie', | ||||
| @@ -29,14 +30,21 @@ function isItemInStock() { | ||||
|         'dieser artikel ist bald wieder verfügbar', | ||||
|         'dostępne wkrótce', | ||||
|         'en rupture de stock', | ||||
|         'ist derzeit nicht auf lager', | ||||
|         'isn\'t in stock right now', | ||||
|         'isnt in stock right now', | ||||
|         'isn’t in stock right now', | ||||
|         'item is no longer available', | ||||
|         'let me know when it\'s available', | ||||
|         'mail me when available', | ||||
|         'message if back in stock', | ||||
|         'nachricht bei', | ||||
|         'nicht auf lager', | ||||
|         'nicht lagernd', | ||||
|         'nicht lieferbar', | ||||
|         'nicht verfügbar', | ||||
|         'nicht vorrätig', | ||||
|         'nicht zur verfügung', | ||||
|         'nie znaleziono produktów', | ||||
|         'niet beschikbaar', | ||||
|         'niet leverbaar', | ||||
|         'niet op voorraad', | ||||
| @@ -45,9 +53,10 @@ function isItemInStock() { | ||||
|         'no tickets available', | ||||
|         'not available', | ||||
|         'not currently available', | ||||
|         'not in stock',         | ||||
|         'not in stock', | ||||
|         'notify me when available', | ||||
|         'notify when available',             | ||||
|         'notify me', | ||||
|         'notify when available', | ||||
|         'não estamos a aceitar encomendas', | ||||
|         'out of stock', | ||||
|         'out-of-stock', | ||||
| @@ -57,20 +66,30 @@ function isItemInStock() { | ||||
|         'sold-out', | ||||
|         'temporarily out of stock', | ||||
|         'temporarily unavailable', | ||||
|         'there were no search results for', | ||||
|         'this item is currently unavailable', | ||||
|         'tickets unavailable', | ||||
|         'tijdelijk uitverkocht', | ||||
|         'unavailable nearby', | ||||
|         'unavailable tickets', | ||||
|         'vergriffen', | ||||
|         'vorbestellen', | ||||
|         'vorbestellung ist bald möglich', | ||||
|         'we couldn\'t find any products that match', | ||||
|         'we do not currently have an estimate of when this product will be back in stock.', | ||||
|         'we don\'t know when or if this item will be back in stock.', | ||||
|         'we were not able to find a match', | ||||
|         'when this arrives in stock', | ||||
|         'zur zeit nicht an lager', | ||||
|         '品切れ', | ||||
|         '已售完', | ||||
|         '已售', | ||||
|         '已售完', | ||||
|         '품절' | ||||
|     ]; | ||||
|  | ||||
|  | ||||
|     const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); | ||||
|  | ||||
|     function getElementBaseText(element) { | ||||
|         // .textContent can include text from children which may give the wrong results | ||||
|         // scan only immediate TEXT_NODEs, which will be a child of the element | ||||
| @@ -81,29 +100,69 @@ function isItemInStock() { | ||||
|         return text.toLowerCase().trim(); | ||||
|     } | ||||
|  | ||||
|     const negateOutOfStockRegex = new RegExp('([0-9] in stock|add to cart)', 'ig'); | ||||
|     const negateOutOfStockRegex = new RegExp('^([0-9] in stock|add to cart|in stock)', 'ig'); | ||||
|  | ||||
|     // The out-of-stock or in-stock-text is generally always above-the-fold | ||||
|     // and often below-the-fold is a list of related products that may or may not contain trigger text | ||||
|     // so it's good to filter to just the 'above the fold' elements | ||||
|     // and it should be atleast 100px from the top to ignore items in the toolbar, sometimes menu items like "Coming soon" exist | ||||
|     const elementsToScan = Array.from(document.getElementsByTagName('*')).filter(element => element.getBoundingClientRect().top + window.scrollY <= vh && element.getBoundingClientRect().top + window.scrollY >= 100); | ||||
|  | ||||
|  | ||||
| // @todo - if it's SVG or IMG, go into image diff mode | ||||
| // %ELEMENTS% replaced at injection time because different interfaces use it with different settings | ||||
|  | ||||
|     console.log("Scanning %ELEMENTS%"); | ||||
|  | ||||
|     function collectVisibleElements(parent, visibleElements) { | ||||
|         if (!parent) return; // Base case: if parent is null or undefined, return | ||||
|  | ||||
|         // Add the parent itself to the visible elements array if it's of the specified types | ||||
|         visibleElements.push(parent); | ||||
|  | ||||
|         // Iterate over the parent's children | ||||
|         const children = parent.children; | ||||
|         for (let i = 0; i < children.length; i++) { | ||||
|             const child = children[i]; | ||||
|             if ( | ||||
|                 child.nodeType === Node.ELEMENT_NODE && | ||||
|                 window.getComputedStyle(child).display !== 'none' && | ||||
|                 window.getComputedStyle(child).visibility !== 'hidden' && | ||||
|                 child.offsetWidth >= 0 && | ||||
|                 child.offsetHeight >= 0 && | ||||
|                 window.getComputedStyle(child).contentVisibility !== 'hidden' | ||||
|             ) { | ||||
|                 // If the child is an element and is visible, recursively collect visible elements | ||||
|                 collectVisibleElements(child, visibleElements); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     const elementsToScan = []; | ||||
|     collectVisibleElements(document.body, elementsToScan); | ||||
|  | ||||
|     var elementText = ""; | ||||
|  | ||||
|     // REGEXS THAT REALLY MEAN IT'S IN STOCK | ||||
|     for (let i = elementsToScan.length - 1; i >= 0; i--) { | ||||
|         const element = elementsToScan[i]; | ||||
|  | ||||
|         // outside the 'fold' or some weird text in the heading area | ||||
|         // .getBoundingClientRect() was causing a crash in chrome 119, can only be run on contentVisibility != hidden | ||||
|         if (element.getBoundingClientRect().top + window.scrollY >= vh || element.getBoundingClientRect().top + window.scrollY <= 100) { | ||||
|             continue | ||||
|         } | ||||
|  | ||||
|         elementText = ""; | ||||
|         if (element.tagName.toLowerCase() === "input") { | ||||
|             elementText = element.value.toLowerCase(); | ||||
|             elementText = element.value.toLowerCase().trim(); | ||||
|         } else { | ||||
|             elementText = getElementBaseText(element); | ||||
|         } | ||||
|  | ||||
|         if (elementText.length) { | ||||
|             // try which ones could mean its in stock | ||||
|             if (negateOutOfStockRegex.test(elementText)) { | ||||
|             if (negateOutOfStockRegex.test(elementText) && !elementText.includes('(0 products)')) { | ||||
|                 console.log(`Negating/overriding 'Out of Stock' back to "Possibly in stock" found "${elementText}"`) | ||||
|                 return 'Possibly in stock'; | ||||
|             } | ||||
|         } | ||||
| @@ -112,28 +171,34 @@ function isItemInStock() { | ||||
|     // OTHER STUFF THAT COULD BE THAT IT'S OUT OF STOCK | ||||
|     for (let i = elementsToScan.length - 1; i >= 0; i--) { | ||||
|         const element = elementsToScan[i]; | ||||
|         if (element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0) { | ||||
|             elementText = ""; | ||||
|             if (element.tagName.toLowerCase() === "input") { | ||||
|                 elementText = element.value.toLowerCase(); | ||||
|             } else { | ||||
|                 elementText = getElementBaseText(element); | ||||
|             } | ||||
|         // outside the 'fold' or some weird text in the heading area | ||||
|         // .getBoundingClientRect() was causing a crash in chrome 119, can only be run on contentVisibility != hidden | ||||
|         if (element.getBoundingClientRect().top + window.scrollY >= vh + 150 || element.getBoundingClientRect().top + window.scrollY <= 100) { | ||||
|             continue | ||||
|         } | ||||
|         elementText = ""; | ||||
|         if (element.tagName.toLowerCase() === "input") { | ||||
|             elementText = element.value.toLowerCase().trim(); | ||||
|         } else { | ||||
|             elementText = getElementBaseText(element); | ||||
|         } | ||||
|  | ||||
|             if (elementText.length) { | ||||
|                 // and these mean its out of stock | ||||
|                 for (const outOfStockText of outOfStockTexts) { | ||||
|                     if (elementText.includes(outOfStockText)) { | ||||
|                         return outOfStockText; // item is out of stock | ||||
|                     } | ||||
|         if (elementText.length) { | ||||
|             // and these mean its out of stock | ||||
|             for (const outOfStockText of outOfStockTexts) { | ||||
|                 if (elementText.includes(outOfStockText)) { | ||||
|                     console.log(`Selected 'Out of Stock' - found text "${outOfStockText}" - "${elementText}"`) | ||||
|                     return outOfStockText; // item is out of stock | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     console.log(`Returning 'Possibly in stock' - cant' find any useful matching text`) | ||||
|     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().trim() | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -16,24 +16,23 @@ try { | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| // Include the getXpath script directly, easier than fetching | ||||
| function getxpath(e) { | ||||
|         var n = e; | ||||
|         if (n && n.id) return '//*[@id="' + n.id + '"]'; | ||||
|         for (var o = []; n && Node.ELEMENT_NODE === n.nodeType;) { | ||||
|             for (var i = 0, r = !1, d = n.previousSibling; d;) d.nodeType !== Node.DOCUMENT_TYPE_NODE && d.nodeName === n.nodeName && i++, d = d.previousSibling; | ||||
|             for (d = n.nextSibling; d;) { | ||||
|                 if (d.nodeName === n.nodeName) { | ||||
|                     r = !0; | ||||
|                     break | ||||
|                 } | ||||
|                 d = d.nextSibling | ||||
|     var n = e; | ||||
|     if (n && n.id) return '//*[@id="' + n.id + '"]'; | ||||
|     for (var o = []; n && Node.ELEMENT_NODE === n.nodeType;) { | ||||
|         for (var i = 0, r = !1, d = n.previousSibling; d;) d.nodeType !== Node.DOCUMENT_TYPE_NODE && d.nodeName === n.nodeName && i++, d = d.previousSibling; | ||||
|         for (d = n.nextSibling; d;) { | ||||
|             if (d.nodeName === n.nodeName) { | ||||
|                 r = !0; | ||||
|                 break | ||||
|             } | ||||
|             o.push((n.prefix ? n.prefix + ":" : "") + n.localName + (i || r ? "[" + (i + 1) + "]" : "")), n = n.parentNode | ||||
|             d = d.nextSibling | ||||
|         } | ||||
|         return o.length ? "/" + o.reverse().join("/") : "" | ||||
|         o.push((n.prefix ? n.prefix + ":" : "") + n.localName + (i || r ? "[" + (i + 1) + "]" : "")), n = n.parentNode | ||||
|     } | ||||
|     return o.length ? "/" + o.reverse().join("/") : "" | ||||
| } | ||||
|  | ||||
| const findUpTag = (el) => { | ||||
|     let r = el | ||||
| @@ -59,14 +58,14 @@ const findUpTag = (el) => { | ||||
|  | ||||
|     // Strategy 2: Keep going up until we hit an ID tag, imagine it's like  #list-widget div h4 | ||||
|     while (r.parentNode) { | ||||
|         if (depth == 5) { | ||||
|         if (depth === 5) { | ||||
|             break; | ||||
|         } | ||||
|         if ('' !== r.id) { | ||||
|             chained_css.unshift("#" + CSS.escape(r.id)); | ||||
|             final_selector = chained_css.join(' > '); | ||||
|             // Be sure theres only one, some sites have multiples of the same ID tag :-( | ||||
|             if (window.document.querySelectorAll(final_selector).length == 1) { | ||||
|             if (window.document.querySelectorAll(final_selector).length === 1) { | ||||
|                 return final_selector; | ||||
|             } | ||||
|             return null; | ||||
| @@ -82,30 +81,60 @@ const findUpTag = (el) => { | ||||
|  | ||||
| // @todo - if it's SVG or IMG, go into image diff mode | ||||
| // %ELEMENTS% replaced at injection time because different interfaces use it with different settings | ||||
| var elements = window.document.querySelectorAll("%ELEMENTS%"); | ||||
|  | ||||
| var size_pos = []; | ||||
| // after page fetch, inject this JS | ||||
| // build a map of all elements and their positions (maybe that only include text?) | ||||
| var bbox; | ||||
| for (var i = 0; i < elements.length; i++) { | ||||
|     bbox = elements[i].getBoundingClientRect(); | ||||
| console.log("Scanning %ELEMENTS%"); | ||||
|  | ||||
|     // Exclude items that are not interactable or visible | ||||
|     if(elements[i].style.opacity === "0") { | ||||
|         continue | ||||
| function collectVisibleElements(parent, visibleElements) { | ||||
|     if (!parent) return; // Base case: if parent is null or undefined, return | ||||
|  | ||||
|  | ||||
|     // Add the parent itself to the visible elements array if it's of the specified types | ||||
|     const tagName = parent.tagName.toLowerCase(); | ||||
|     if ("%ELEMENTS%".split(',').includes(tagName)) { | ||||
|         visibleElements.push(parent); | ||||
|     } | ||||
|     if(elements[i].style.display === "none" || elements[i].style.pointerEvents === "none" ) { | ||||
|         continue | ||||
|  | ||||
|     // Iterate over the parent's children | ||||
|     const children = parent.children; | ||||
|     for (let i = 0; i < children.length; i++) { | ||||
|         const child = children[i]; | ||||
|         if ( | ||||
|             child.nodeType === Node.ELEMENT_NODE && | ||||
|             window.getComputedStyle(child).display !== 'none' && | ||||
|             window.getComputedStyle(child).visibility !== 'hidden' && | ||||
|             child.offsetWidth >= 0 && | ||||
|             child.offsetHeight >= 0 && | ||||
|             window.getComputedStyle(child).contentVisibility !== 'hidden' | ||||
|         ) { | ||||
|             // If the child is an element and is visible, recursively collect visible elements | ||||
|             collectVisibleElements(child, visibleElements); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Create an array to hold the visible elements | ||||
| const visibleElementsArray = []; | ||||
|  | ||||
| // Call collectVisibleElements with the starting parent element | ||||
| collectVisibleElements(document.body, visibleElementsArray); | ||||
|  | ||||
|  | ||||
| visibleElementsArray.forEach(function (element) { | ||||
|  | ||||
|     bbox = element.getBoundingClientRect(); | ||||
|  | ||||
|     // Skip really small ones, and where width or height ==0 | ||||
|     if (bbox['width'] * bbox['height'] < 100) { | ||||
|         continue; | ||||
|     if (bbox['width'] * bbox['height'] < 10) { | ||||
|         return | ||||
|     } | ||||
|  | ||||
|     // Don't include elements that are offset from canvas | ||||
|     if (bbox['top']+scroll_y < 0 || bbox['left'] < 0) { | ||||
|         continue; | ||||
|     if (bbox['top'] + scroll_y < 0 || bbox['left'] < 0) { | ||||
|         return | ||||
|     } | ||||
|  | ||||
|     // @todo the getXpath kind of sucks, it doesnt know when there is for example just one ID sometimes | ||||
| @@ -114,50 +143,46 @@ for (var i = 0; i < elements.length; i++) { | ||||
|  | ||||
|     // 1st primitive - if it has class, try joining it all and select, if theres only one.. well thats us. | ||||
|     xpath_result = false; | ||||
|  | ||||
|     try { | ||||
|         var d = findUpTag(elements[i]); | ||||
|         var d = findUpTag(element); | ||||
|         if (d) { | ||||
|             xpath_result = d; | ||||
|         } | ||||
|     } catch (e) { | ||||
|         console.log(e); | ||||
|     } | ||||
|  | ||||
|     // You could swap it and default to getXpath and then try the smarter one | ||||
|     // default back to the less intelligent one | ||||
|     if (!xpath_result) { | ||||
|         try { | ||||
|             // I've seen on FB and eBay that this doesnt work | ||||
|             // ReferenceError: getXPath is not defined at eval (eval at evaluate (:152:29), <anonymous>:67:20) at UtilityScript.evaluate (<anonymous>:159:18) at UtilityScript.<anonymous> (<anonymous>:1:44) | ||||
|             xpath_result = getxpath(elements[i]); | ||||
|             xpath_result = getxpath(element); | ||||
|         } catch (e) { | ||||
|             console.log(e); | ||||
|             continue; | ||||
|             return | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if (window.getComputedStyle(elements[i]).visibility === "hidden") { | ||||
|         continue; | ||||
|     } | ||||
|  | ||||
|     // @todo Possible to ONLY list where it's clickable to save JSON xfer size | ||||
|     size_pos.push({ | ||||
|         xpath: xpath_result, | ||||
|         width: Math.round(bbox['width']), | ||||
|         height: Math.round(bbox['height']), | ||||
|         left: Math.floor(bbox['left']), | ||||
|         top: Math.floor(bbox['top'])+scroll_y, | ||||
|         tagName: (elements[i].tagName) ? elements[i].tagName.toLowerCase() : '', | ||||
|         tagtype: (elements[i].tagName == 'INPUT' && elements[i].type) ? elements[i].type.toLowerCase() : '', | ||||
|         isClickable: (elements[i].onclick) || window.getComputedStyle(elements[i]).cursor == "pointer" | ||||
|         top: Math.floor(bbox['top']) + scroll_y, | ||||
|         tagName: (element.tagName) ? element.tagName.toLowerCase() : '', | ||||
|         tagtype: (element.tagName.toLowerCase() === 'input' && element.type) ? element.type.toLowerCase() : '', | ||||
|         isClickable: window.getComputedStyle(element).cursor == "pointer" | ||||
|     }); | ||||
|  | ||||
| } | ||||
| }); | ||||
|  | ||||
|  | ||||
| // Inject the current one set in the include_filters, which may be a CSS rule | ||||
| // used for displaying the current one in VisualSelector, where its not one we generated. | ||||
| if (include_filters.length) { | ||||
|     let results; | ||||
|     // Foreach filter, go and find it on the page and add it to the results so we can visualise it again | ||||
|     for (const f of include_filters) { | ||||
|         bbox = false; | ||||
| @@ -173,55 +198,61 @@ if (include_filters.length) { | ||||
|             if (f.startsWith('/') || f.startsWith('xpath')) { | ||||
|                 var qry_f = f.replace(/xpath(:|\d:)/, '') | ||||
|                 console.log("[xpath] Scanning for included filter " + qry_f) | ||||
|                 q = document.evaluate(qry_f, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; | ||||
|                 let xpathResult = document.evaluate(qry_f, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); | ||||
|                 results = []; | ||||
|                 for (let i = 0; i < xpathResult.snapshotLength; i++) { | ||||
|                     results.push(xpathResult.snapshotItem(i)); | ||||
|                 } | ||||
|             } else { | ||||
|                 console.log("[css] Scanning for included filter " + f) | ||||
|                 q = document.querySelector(f); | ||||
|                 console.log("[css] Scanning for included filter " + f); | ||||
|                 results = document.querySelectorAll(f); | ||||
|             } | ||||
|         } catch (e) { | ||||
|             // Maybe catch DOMException and alert? | ||||
|             console.log("xpath_element_scraper: Exception selecting element from filter "+f); | ||||
|             console.log("xpath_element_scraper: Exception selecting element from filter " + f); | ||||
|             console.log(e); | ||||
|         } | ||||
|  | ||||
|         if (q) { | ||||
|             // Try to resolve //something/text() back to its /something so we can atleast get the bounding box | ||||
|             try { | ||||
|                 if (typeof q.nodeName == 'string' && q.nodeName === '#text') { | ||||
|                     q = q.parentElement | ||||
|                 } | ||||
|             } catch (e) { | ||||
|                 console.log(e) | ||||
|                 console.log("xpath_element_scraper: #text resolver") | ||||
|             } | ||||
|         if (results.length) { | ||||
|  | ||||
|             // #1231 - IN the case XPath attribute filter is applied, we will have to traverse up and find the element. | ||||
|             if (typeof q.getBoundingClientRect == 'function') { | ||||
|                 bbox = q.getBoundingClientRect(); | ||||
|                 console.log("xpath_element_scraper: Got filter element, scroll from top was " + scroll_y) | ||||
|             } else { | ||||
|             // Iterate over the results | ||||
|             results.forEach(node => { | ||||
|                 // Try to resolve //something/text() back to its /something so we can atleast get the bounding box | ||||
|                 try { | ||||
|                     // Try and see we can find its ownerElement | ||||
|                     bbox = q.ownerElement.getBoundingClientRect(); | ||||
|                     console.log("xpath_element_scraper: Got filter by ownerElement element, scroll from top was " + scroll_y) | ||||
|                     if (typeof node.nodeName == 'string' && node.nodeName === '#text') { | ||||
|                         node = node.parentElement | ||||
|                     } | ||||
|                 } catch (e) { | ||||
|                     console.log(e) | ||||
|                     console.log("xpath_element_scraper: error looking up q.ownerElement") | ||||
|                     console.log("xpath_element_scraper: #text resolver") | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         if(!q) { | ||||
|             console.log("xpath_element_scraper: filter element " + f + " was not found"); | ||||
|         } | ||||
|  | ||||
|         if (bbox && bbox['width'] > 0 && bbox['height'] > 0) { | ||||
|             size_pos.push({ | ||||
|                 xpath: f, | ||||
|                 width: parseInt(bbox['width']), | ||||
|                 height: parseInt(bbox['height']), | ||||
|                 left: parseInt(bbox['left']), | ||||
|                 top: parseInt(bbox['top'])+scroll_y | ||||
|                 // #1231 - IN the case XPath attribute filter is applied, we will have to traverse up and find the element. | ||||
|                 if (typeof node.getBoundingClientRect == 'function') { | ||||
|                     bbox = node.getBoundingClientRect(); | ||||
|                     console.log("xpath_element_scraper: Got filter element, scroll from top was " + scroll_y) | ||||
|                 } else { | ||||
|                     try { | ||||
|                         // Try and see we can find its ownerElement | ||||
|                         bbox = node.ownerElement.getBoundingClientRect(); | ||||
|                         console.log("xpath_element_scraper: Got filter by ownerElement element, scroll from top was " + scroll_y) | ||||
|                     } catch (e) { | ||||
|                         console.log(e) | ||||
|                         console.log("xpath_element_scraper: error looking up q.ownerElement") | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if (bbox && bbox['width'] > 0 && bbox['height'] > 0) { | ||||
|                     size_pos.push({ | ||||
|                         xpath: f, | ||||
|                         width: parseInt(bbox['width']), | ||||
|                         height: parseInt(bbox['height']), | ||||
|                         left: parseInt(bbox['left']), | ||||
|                         top: parseInt(bbox['top']) + scroll_y, | ||||
|                         highlight_as_custom_filter: true | ||||
|                     }); | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| @@ -229,7 +260,7 @@ if (include_filters.length) { | ||||
|  | ||||
| // Sort the elements so we find the smallest one first, in other words, we find the smallest one matching in that area | ||||
| // so that we dont select the wrapping element by mistake and be unable to select what we want | ||||
| size_pos.sort((a, b) => (a.width*a.height > b.width*b.height) ? 1 : -1) | ||||
| size_pos.sort((a, b) => (a.width * a.height > b.width * b.height) ? 1 : -1) | ||||
|  | ||||
| // Window.width required for proper scaling in the frontend | ||||
| return {'size_pos': size_pos, 'browser_width': window.innerWidth}; | ||||
|   | ||||
| @@ -1,62 +1,97 @@ | ||||
| # used for the notifications, the front-end is using a JS library | ||||
|  | ||||
| import difflib | ||||
| from typing import List, Iterator, Union | ||||
|  | ||||
| def same_slicer(lst: List[str], start: int, end: int) -> List[str]: | ||||
|     """Return a slice of the list, or a single element if start == end.""" | ||||
|     return lst[start:end] if start != end else [lst[start]] | ||||
|  | ||||
| def same_slicer(l, a, b): | ||||
|     if a == b: | ||||
|         return [l[a]] | ||||
|     else: | ||||
|         return l[a:b] | ||||
|  | ||||
| # like .compare but a little different output | ||||
| 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?) | ||||
| def customSequenceMatcher( | ||||
|     before: List[str], | ||||
|     after: List[str], | ||||
|     include_equal: bool = False, | ||||
|     include_removed: bool = True, | ||||
|     include_added: bool = True, | ||||
|     include_replaced: bool = True, | ||||
|     include_change_type_prefix: bool = True | ||||
| ) -> Iterator[List[str]]: | ||||
|     """ | ||||
|     Compare two sequences and yield differences based on specified parameters. | ||||
|      | ||||
|     Args: | ||||
|         before (List[str]): Original sequence | ||||
|         after (List[str]): Modified sequence | ||||
|         include_equal (bool): Include unchanged parts | ||||
|         include_removed (bool): Include removed parts | ||||
|         include_added (bool): Include added parts | ||||
|         include_replaced (bool): Include replaced parts | ||||
|         include_change_type_prefix (bool): Add prefixes to indicate change types | ||||
|      | ||||
|     Yields: | ||||
|         List[str]: Differences between sequences | ||||
|     """ | ||||
|     cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in " \t", a=before, b=after) | ||||
|      | ||||
|     for tag, alo, ahi, blo, bhi in cruncher.get_opcodes(): | ||||
|         if include_equal and tag == 'equal': | ||||
|             g = before[alo:ahi] | ||||
|             yield g | ||||
|             yield before[alo:ahi] | ||||
|         elif include_removed and tag == 'delete': | ||||
|             row_prefix = "(removed) " if include_change_type_prefix else '' | ||||
|             g = [ row_prefix + i for i in same_slicer(before, alo, ahi)] | ||||
|             yield g | ||||
|             prefix = "(removed) " if include_change_type_prefix else '' | ||||
|             yield [f"{prefix}{line}" for line in same_slicer(before, alo, ahi)] | ||||
|         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 | ||||
|             prefix_changed = "(changed) " if include_change_type_prefix else '' | ||||
|             prefix_into = "(into) " if include_change_type_prefix else '' | ||||
|             yield [f"{prefix_changed}{line}" for line in same_slicer(before, alo, ahi)] + \ | ||||
|                   [f"{prefix_into}{line}" for line in same_slicer(after, blo, bhi)] | ||||
|         elif include_added and tag == 'insert': | ||||
|             row_prefix = "(added) " if include_change_type_prefix else '' | ||||
|             g = [row_prefix + i for i in same_slicer(after, blo, bhi)] | ||||
|             yield g | ||||
|             prefix = "(added) " if include_change_type_prefix else '' | ||||
|             yield [f"{prefix}{line}" for line in same_slicer(after, blo, bhi)] | ||||
|  | ||||
| # 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_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, patch_format=False): | ||||
|  | ||||
|     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 = "" | ||||
| def render_diff( | ||||
|     previous_version_file_contents: str, | ||||
|     newest_version_file_contents: str, | ||||
|     include_equal: bool = False, | ||||
|     include_removed: bool = True, | ||||
|     include_added: bool = True, | ||||
|     include_replaced: bool = True, | ||||
|     line_feed_sep: str = "\n", | ||||
|     include_change_type_prefix: bool = True, | ||||
|     patch_format: bool = False | ||||
| ) -> str: | ||||
|     """ | ||||
|     Render the difference between two file contents. | ||||
|      | ||||
|     Args: | ||||
|         previous_version_file_contents (str): Original file contents | ||||
|         newest_version_file_contents (str): Modified file contents | ||||
|         include_equal (bool): Include unchanged parts | ||||
|         include_removed (bool): Include removed parts | ||||
|         include_added (bool): Include added parts | ||||
|         include_replaced (bool): Include replaced parts | ||||
|         line_feed_sep (str): Separator for lines in output | ||||
|         include_change_type_prefix (bool): Add prefixes to indicate change types | ||||
|         patch_format (bool): Use patch format for output | ||||
|      | ||||
|     Returns: | ||||
|         str: Rendered difference | ||||
|     """ | ||||
|     newest_lines = [line.rstrip() for line in newest_version_file_contents.splitlines()] | ||||
|     previous_lines = [line.rstrip() for line in previous_version_file_contents.splitlines()] if previous_version_file_contents else [] | ||||
|  | ||||
|     if patch_format: | ||||
|         patch = difflib.unified_diff(previous_version_file_contents, newest_version_file_contents) | ||||
|         patch = difflib.unified_diff(previous_lines, newest_lines) | ||||
|         return line_feed_sep.join(patch) | ||||
|  | ||||
|     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) | ||||
|     rendered_diff = customSequenceMatcher( | ||||
|         before=previous_lines, | ||||
|         after=newest_lines, | ||||
|         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]) | ||||
|     p= f(rendered_diff) | ||||
|     return p | ||||
|     def flatten(lst: List[Union[str, List[str]]]) -> str: | ||||
|         return line_feed_sep.join(flatten(x) if isinstance(x, list) else x for x in lst) | ||||
|  | ||||
|     return flatten(rendered_diff) | ||||
|   | ||||
| @@ -5,11 +5,11 @@ import os | ||||
| import queue | ||||
| import threading | ||||
| import time | ||||
| from .safe_jinja import render as jinja_render | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from copy import deepcopy | ||||
| from distutils.util import strtobool | ||||
| from functools import wraps | ||||
| from threading import Event | ||||
|  | ||||
| import flask_login | ||||
| import pytz | ||||
| import timeago | ||||
| @@ -30,6 +30,7 @@ from flask_compress import Compress as FlaskCompress | ||||
| from flask_login import current_user | ||||
| from flask_paginate import Pagination, get_page_parameter | ||||
| from flask_restful import abort, Api | ||||
| from flask_cors import CORS | ||||
| from flask_wtf import CSRFProtect | ||||
| from loguru import logger | ||||
|  | ||||
| @@ -53,6 +54,9 @@ app = Flask(__name__, | ||||
|             static_folder="static", | ||||
|             template_folder="templates") | ||||
|  | ||||
| # Enable CORS, especially useful for the Chrome extension to operate from anywhere | ||||
| CORS(app) | ||||
|  | ||||
| # Super handy for compressing large BrowserSteps responses and others | ||||
| FlaskCompress(app) | ||||
|  | ||||
| @@ -120,10 +124,10 @@ def _jinja2_filter_datetime(watch_obj, format="%Y-%m-%d %H:%M:%S"): | ||||
|  | ||||
| @app.template_filter('format_timestamp_timeago') | ||||
| def _jinja2_filter_datetimestamp(timestamp, format="%Y-%m-%d %H:%M:%S"): | ||||
|     if timestamp == False: | ||||
|     if not timestamp: | ||||
|         return 'Not yet' | ||||
|  | ||||
|     return timeago.format(timestamp, time.time()) | ||||
|     return timeago.format(int(timestamp), time.time()) | ||||
|  | ||||
|  | ||||
| @app.template_filter('pagination_slice') | ||||
| @@ -315,8 +319,6 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|     @app.route("/rss", methods=['GET']) | ||||
|     def rss(): | ||||
|         from jinja2 import Environment, BaseLoader | ||||
|         jinja2_env = Environment(loader=BaseLoader) | ||||
|         now = time.time() | ||||
|         # Always requires token set | ||||
|         app_rss_token = datastore.data['settings']['application'].get('rss_access_token') | ||||
| @@ -336,8 +338,11 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         # @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away | ||||
|         for uuid, watch in datastore.data['watching'].items(): | ||||
|             # @todo tag notification_muted skip also (improve Watch model) | ||||
|             if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'): | ||||
|                 continue | ||||
|             if limit_tag and not limit_tag in watch['tags']: | ||||
|                     continue | ||||
|                 continue | ||||
|             watch['uuid'] = uuid | ||||
|             sorted_watches.append(watch) | ||||
|  | ||||
| @@ -384,7 +389,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                 # @todo Make this configurable and also consider html-colored markup | ||||
|                 # @todo User could decide if <link> goes to the diff page, or to the watch link | ||||
|                 rss_template = "<html><body>\n<h4><a href=\"{{watch_url}}\">{{watch_title}}</a></h4>\n<p>{{html_diff}}</p>\n</body></html>\n" | ||||
|                 content = jinja2_env.from_string(rss_template).render(watch_title=watch_title, html_diff=html_diff, watch_url=watch.link) | ||||
|                 content = jinja_render(template_str=rss_template, watch_title=watch_title, html_diff=html_diff, watch_url=watch.link) | ||||
|  | ||||
|                 fe.content(content=content, type='CDATA') | ||||
|  | ||||
| @@ -448,6 +453,8 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             if search_q: | ||||
|                 if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower(): | ||||
|                     sorted_watches.append(watch) | ||||
|                 elif watch.get('last_error') and search_q in watch.get('last_error').lower(): | ||||
|                     sorted_watches.append(watch) | ||||
|             else: | ||||
|                 sorted_watches.append(watch) | ||||
|  | ||||
| @@ -465,7 +472,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                                  # Don't link to hosting when we're on the hosting environment | ||||
|                                  active_tag=active_tag, | ||||
|                                  active_tag_uuid=active_tag_uuid, | ||||
|                                  app_rss_token=datastore.data['settings']['application']['rss_access_token'], | ||||
|                                  app_rss_token=datastore.data['settings']['application'].get('rss_access_token'), | ||||
|                                  datastore=datastore, | ||||
|                                  errored_count=errored_count, | ||||
|                                  form=form, | ||||
| @@ -512,21 +519,38 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         watch = datastore.data['watching'].get(watch_uuid) if watch_uuid else None | ||||
|  | ||||
|         # validate URLS | ||||
|         if not len(request.form['notification_urls'].strip()): | ||||
|             return make_response({'error': 'No Notification URLs set'}, 400) | ||||
|         notification_urls = request.form['notification_urls'].strip().splitlines() | ||||
|  | ||||
|         for server_url in request.form['notification_urls'].splitlines(): | ||||
|             if len(server_url.strip()): | ||||
|                 if not apobj.add(server_url): | ||||
|                     message = '{} is not a valid AppRise URL.'.format(server_url) | ||||
|                     return make_response({'error': message}, 400) | ||||
|         if not notification_urls: | ||||
|             logger.debug("Test notification - Trying by group/tag in the edit form if available") | ||||
|             # On an edit page, we should also fire off to the tags if they have notifications | ||||
|             if request.form.get('tags') and request.form['tags'].strip(): | ||||
|                 for k in request.form['tags'].split(','): | ||||
|                     tag = datastore.tag_exists_by_name(k.strip()) | ||||
|                     notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None | ||||
|  | ||||
|         is_global_settings_form = request.args.get('mode', '') == 'global-settings' | ||||
|         is_group_settings_form = request.args.get('mode', '') == 'group-settings' | ||||
|         if not notification_urls and not is_global_settings_form and not is_group_settings_form: | ||||
|             # In the global settings, use only what is typed currently in the text box | ||||
|             logger.debug("Test notification - Trying by global system settings notifications") | ||||
|             if datastore.data['settings']['application'].get('notification_urls'): | ||||
|                 notification_urls = datastore.data['settings']['application']['notification_urls'] | ||||
|  | ||||
|  | ||||
|         if not notification_urls: | ||||
|             return 'No Notification URLs set/found' | ||||
|  | ||||
|         for n_url in notification_urls: | ||||
|             if len(n_url.strip()): | ||||
|                 if not apobj.add(n_url): | ||||
|                     return f'Error - {n_url} is not a valid AppRise URL.' | ||||
|  | ||||
|         try: | ||||
|             # use the same as when it is triggered, but then override it with the form test values | ||||
|             n_object = { | ||||
|                 'watch_url': request.form['window_url'], | ||||
|                 'notification_urls': request.form['notification_urls'].splitlines() | ||||
|                 'notification_urls': notification_urls | ||||
|             } | ||||
|  | ||||
|             # Only use if present, if not set in n_object it should use the default system value | ||||
| @@ -545,7 +569,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         except Exception as e: | ||||
|             return make_response({'error': str(e)}, 400) | ||||
|  | ||||
|         return 'OK' | ||||
|         return 'OK - Sent test notifications' | ||||
|  | ||||
|  | ||||
|     @app.route("/clear_history/<string:uuid>", methods=['GET']) | ||||
| @@ -582,6 +606,12 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         output = render_template("clear_all_history.html") | ||||
|         return output | ||||
|  | ||||
|     def _watch_has_tag_options_set(watch): | ||||
|         """This should be fixed better so that Tag is some proper Model, a tag is just a Watch also""" | ||||
|         for tag_uuid, tag in datastore.data['settings']['application'].get('tags', {}).items(): | ||||
|             if tag_uuid in watch.get('tags', []) and (tag.get('include_filters') or tag.get('subtractive_selectors')): | ||||
|                 return True | ||||
|  | ||||
|     @app.route("/edit/<string:uuid>", methods=['GET', 'POST']) | ||||
|     @login_optionally_required | ||||
|     # https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists | ||||
| @@ -592,7 +622,6 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         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 | ||||
|         if not datastore.data['watching'].keys(): | ||||
|             flash("No watches to edit", "error") | ||||
| @@ -617,10 +646,6 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         # be sure we update with a copy instead of accidently editing the live object by reference | ||||
|         default = deepcopy(datastore.data['watching'][uuid]) | ||||
|  | ||||
|         # Show system wide default if nothing configured | ||||
|         if all(value == 0 or value == None for value in datastore.data['watching'][uuid]['time_between_check'].values()): | ||||
|             default['time_between_check'] = deepcopy(datastore.data['settings']['requests']['time_between_check']) | ||||
|  | ||||
|         # Defaults for proxy choice | ||||
|         if datastore.proxy_list is not None:  # When enabled | ||||
|             # @todo | ||||
| @@ -654,22 +679,15 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         if request.method == 'POST' and form.validate(): | ||||
|  | ||||
|             extra_update_obj = {} | ||||
|             extra_update_obj = { | ||||
|                 'consecutive_filter_failures': 0, | ||||
|                 'last_error' : False | ||||
|             } | ||||
|  | ||||
|             if request.args.get('unpause_on_save'): | ||||
|                 extra_update_obj['paused'] = False | ||||
|             # Re #110, if they submit the same as the default value, set it to None, so we continue to follow the default | ||||
|             # Assume we use the default value, unless something relevant is different, then use the form value | ||||
|             # values could be None, 0 etc. | ||||
|             # Set to None unless the next for: says that something is different | ||||
|             extra_update_obj['time_between_check'] = dict.fromkeys(form.time_between_check.data) | ||||
|             for k, v in form.time_between_check.data.items(): | ||||
|                 if v and v != datastore.data['settings']['requests']['time_between_check'][k]: | ||||
|                     extra_update_obj['time_between_check'] = form.time_between_check.data | ||||
|                     using_default_check_time = False | ||||
|                     break | ||||
|  | ||||
|  | ||||
|             extra_update_obj['time_between_check'] = form.time_between_check.data | ||||
|  | ||||
|              # Ignore text | ||||
|             form_ignore_text = form.ignore_text.data | ||||
| @@ -703,7 +721,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             datastore.data['watching'][uuid].update(extra_update_obj) | ||||
|  | ||||
|             if request.args.get('unpause_on_save'): | ||||
|                 flash("Updated watch - unpaused!.") | ||||
|                 flash("Updated watch - unpaused!") | ||||
|             else: | ||||
|                 flash("Updated watch.") | ||||
|  | ||||
| @@ -750,13 +768,13 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                                      extra_title=f" - Edit - {watch.label}", | ||||
|                                      form=form, | ||||
|                                      has_default_notification_urls=True if len(datastore.data['settings']['application']['notification_urls']) else False, | ||||
|                                      has_empty_checktime=using_default_check_time, | ||||
|                                      has_extra_headers_file=len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0, | ||||
|                                      has_special_tag_options=_watch_has_tag_options_set(watch=watch), | ||||
|                                      is_html_webdriver=is_html_webdriver, | ||||
|                                      jq_support=jq_support, | ||||
|                                      playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False), | ||||
|                                      settings_application=datastore.data['settings']['application'], | ||||
|                                      using_global_webdriver_wait=default['webdriver_delay'] is None, | ||||
|                                      using_global_webdriver_wait=not default['webdriver_delay'], | ||||
|                                      uuid=uuid, | ||||
|                                      visualselector_enabled=visualselector_enabled, | ||||
|                                      watch=watch | ||||
| @@ -835,11 +853,13 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                 flash("An error occurred, please see below.", "error") | ||||
|  | ||||
|         output = render_template("settings.html", | ||||
|                                  form=form, | ||||
|                                  hide_remove_pass=os.getenv("SALTED_PASS", False), | ||||
|                                  api_key=datastore.data['settings']['application'].get('api_access_token'), | ||||
|                                  emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||
|                                  settings_application=datastore.data['settings']['application']) | ||||
|                                  form=form, | ||||
|                                  hide_remove_pass=os.getenv("SALTED_PASS", False), | ||||
|                                  min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)), | ||||
|                                  settings_application=datastore.data['settings']['application'] | ||||
|                                  ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
| @@ -1049,6 +1069,8 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         content = [] | ||||
|         ignored_line_numbers = [] | ||||
|         trigger_line_numbers = [] | ||||
|         versions = [] | ||||
|         timestamp = None | ||||
|  | ||||
|         # More for testing, possible to return the first/only | ||||
|         if uuid == 'first': | ||||
| @@ -1068,57 +1090,53 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'): | ||||
|             is_html_webdriver = True | ||||
|  | ||||
|         # Never requested successfully, but we detected a fetch error | ||||
|         if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()): | ||||
|             flash("Preview unavailable - No fetch/check completed or triggers not reached", "error") | ||||
|             output = render_template("preview.html", | ||||
|                                      content=content, | ||||
|                                      history_n=watch.history_n, | ||||
|                                      extra_stylesheets=extra_stylesheets, | ||||
| #                                     current_diff_url=watch['url'], | ||||
|                                      watch=watch, | ||||
|                                      uuid=uuid, | ||||
|                                      is_html_webdriver=is_html_webdriver, | ||||
|                                      last_error=watch['last_error'], | ||||
|                                      last_error_text=watch.get_error_text(), | ||||
|                                      last_error_screenshot=watch.get_error_snapshot()) | ||||
|             return output | ||||
|         else: | ||||
|             # So prepare the latest preview or not | ||||
|             preferred_version = request.args.get('version') | ||||
|             versions = list(watch.history.keys()) | ||||
|             timestamp = versions[-1] | ||||
|             if preferred_version and preferred_version in versions: | ||||
|                 timestamp = preferred_version | ||||
|  | ||||
|         timestamp = list(watch.history.keys())[-1] | ||||
|         try: | ||||
|             tmp = watch.get_history_snapshot(timestamp).splitlines() | ||||
|             try: | ||||
|                 versions = list(watch.history.keys()) | ||||
|                 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="\n".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="\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)}) | ||||
|                 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': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''}) | ||||
|             except Exception as e: | ||||
|                 content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''}) | ||||
|  | ||||
|         output = render_template("preview.html", | ||||
|                                  content=content, | ||||
|                                  current_version=timestamp, | ||||
|                                  history_n=watch.history_n, | ||||
|                                  extra_stylesheets=extra_stylesheets, | ||||
|                                  extra_title=f" - Diff - {watch.label} @ {timestamp}", | ||||
|                                  ignored_line_numbers=ignored_line_numbers, | ||||
|                                  triggered_line_numbers=trigger_line_numbers, | ||||
|                                  current_diff_url=watch['url'], | ||||
| @@ -1128,7 +1146,10 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                                  is_html_webdriver=is_html_webdriver, | ||||
|                                  last_error=watch['last_error'], | ||||
|                                  last_error_text=watch.get_error_text(), | ||||
|                                  last_error_screenshot=watch.get_error_snapshot()) | ||||
|                                  last_error_screenshot=watch.get_error_snapshot(), | ||||
|                                  versions=versions | ||||
|                                 ) | ||||
|  | ||||
|  | ||||
|         return output | ||||
|  | ||||
| @@ -1275,9 +1296,8 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         url = request.form.get('url').strip() | ||||
|         if datastore.url_exists(url): | ||||
|             flash('The URL {} already exists'.format(url), "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|             flash(f'Warning, URL {url} already exists', "notice") | ||||
|              | ||||
|         add_paused = request.form.get('edit_and_watch_submit_button') != None | ||||
|         processor = request.form.get('processor', 'text_json_diff') | ||||
|         new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor}) | ||||
| @@ -1427,6 +1447,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-errors'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid]["last_error"] = False | ||||
|             flash(f"{len(uuids)} watches errors cleared") | ||||
|  | ||||
|         elif (op == 'clear-history'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
| @@ -1634,14 +1661,14 @@ def notification_runner(): | ||||
|             # Trim the log length | ||||
|             notification_debug_log = notification_debug_log[-100:] | ||||
|  | ||||
| # Thread runner to check every minute, look for new watches to feed into the Queue. | ||||
| # Threaded runner, look for new watches to feed into the Queue. | ||||
| def ticker_thread_check_time_launch_checks(): | ||||
|     import random | ||||
|     from changedetectionio import update_worker | ||||
|  | ||||
|     proxy_last_called_time = {} | ||||
|  | ||||
|     recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 20)) | ||||
|     recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)) | ||||
|     logger.debug(f"System env MINIMUM_SECONDS_RECHECK_TIME {recheck_time_minimum_seconds}") | ||||
|  | ||||
|     # Spin up Workers that do the fetching | ||||
| @@ -1695,9 +1722,7 @@ def ticker_thread_check_time_launch_checks(): | ||||
|                 continue | ||||
|  | ||||
|             # If they supplied an individual entry minutes to threshold. | ||||
|  | ||||
|             watch_threshold_seconds = watch.threshold_seconds() | ||||
|             threshold = watch_threshold_seconds if watch_threshold_seconds > 0 else recheck_time_system_seconds | ||||
|             threshold = recheck_time_system_seconds if watch.get('time_between_check_use_default') else watch.threshold_seconds() | ||||
|  | ||||
|             # #580 - Jitter plus/minus amount of time to make the check seem more random to the server | ||||
|             jitter = datastore.data['settings']['requests'].get('jitter_seconds', 0) | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import os | ||||
| import re | ||||
| from distutils.util import strtobool | ||||
| from changedetectionio.strtobool import strtobool | ||||
|  | ||||
| from wtforms import ( | ||||
|     BooleanField, | ||||
| @@ -236,21 +236,26 @@ class ValidateJinja2Template(object): | ||||
|     def __call__(self, form, field): | ||||
|         from changedetectionio import notification | ||||
|  | ||||
|         from jinja2 import Environment, BaseLoader, TemplateSyntaxError, UndefinedError | ||||
|         from jinja2 import BaseLoader, TemplateSyntaxError, UndefinedError | ||||
|         from jinja2.sandbox import ImmutableSandboxedEnvironment | ||||
|         from jinja2.meta import find_undeclared_variables | ||||
|         import jinja2.exceptions | ||||
|  | ||||
|         # Might be a list of text, or might be just text (like from the apprise url list) | ||||
|         joined_data = ' '.join(map(str, field.data)) if isinstance(field.data, list) else f"{field.data}" | ||||
|  | ||||
|         try: | ||||
|             jinja2_env = Environment(loader=BaseLoader) | ||||
|             jinja2_env = ImmutableSandboxedEnvironment(loader=BaseLoader) | ||||
|             jinja2_env.globals.update(notification.valid_tokens) | ||||
|  | ||||
|             rendered = jinja2_env.from_string(field.data).render() | ||||
|             jinja2_env.from_string(joined_data).render() | ||||
|         except TemplateSyntaxError as e: | ||||
|             raise ValidationError(f"This is not a valid Jinja2 template: {e}") from e | ||||
|         except UndefinedError as e: | ||||
|             raise ValidationError(f"A variable or function is not defined: {e}") from e | ||||
|         except jinja2.exceptions.SecurityError as e: | ||||
|             raise ValidationError(f"This is not a valid Jinja2 template: {e}") from e | ||||
|  | ||||
|         ast = jinja2_env.parse(field.data) | ||||
|         ast = jinja2_env.parse(joined_data) | ||||
|         undefined = ", ".join(find_undeclared_variables(ast)) | ||||
|         if undefined: | ||||
|             raise ValidationError( | ||||
| @@ -415,7 +420,7 @@ class quickWatchForm(Form): | ||||
| # Common to a single watch and the global settings | ||||
| class commonSettingsForm(Form): | ||||
|  | ||||
|     notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers()]) | ||||
|     notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()]) | ||||
|     notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()]) | ||||
|     notification_body = TextAreaField('Notification Body', default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()]) | ||||
|     notification_format = SelectField('Notification format', choices=valid_notification_formats.keys()) | ||||
| @@ -448,6 +453,7 @@ class watchForm(commonSettingsForm): | ||||
|     tags = StringTagUUID('Group tag', [validators.Optional()], default='') | ||||
|  | ||||
|     time_between_check = FormField(TimeBetweenCheckForm) | ||||
|     time_between_check_use_default = BooleanField('Use global settings for time between check', default=False) | ||||
|  | ||||
|     include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='') | ||||
|  | ||||
| @@ -499,11 +505,9 @@ class watchForm(commonSettingsForm): | ||||
|             result = False | ||||
|  | ||||
|         # Attempt to validate jinja2 templates in the URL | ||||
|         from jinja2 import Environment | ||||
|         # Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/ | ||||
|         jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||
|         try: | ||||
|             ready_url = str(jinja2_env.from_string(self.url.data).render()) | ||||
|             from changedetectionio.safe_jinja import render as jinja_render | ||||
|             jinja_render(template_str=self.url.data) | ||||
|         except Exception as e: | ||||
|             self.url.errors.append('Invalid template syntax') | ||||
|             result = False | ||||
| @@ -522,6 +526,10 @@ class SingleExtraBrowser(Form): | ||||
|     browser_connection_url = StringField('Browser connection URL', [validators.Optional()], render_kw={"placeholder": "wss://brightdata... wss://oxylabs etc", "size":50}) | ||||
|     # @todo do the validation here instead | ||||
|  | ||||
| class DefaultUAInputForm(Form): | ||||
|     html_requests = StringField('Plaintext requests', validators=[validators.Optional()], render_kw={"placeholder": "<default>"}) | ||||
|     if os.getenv("PLAYWRIGHT_DRIVER_URL") or os.getenv("WEBDRIVER_URL"): | ||||
|         html_webdriver = StringField('Chrome requests', validators=[validators.Optional()], render_kw={"placeholder": "<default>"}) | ||||
|  | ||||
| # datastore.data['settings']['requests'].. | ||||
| class globalSettingsRequestForm(Form): | ||||
| @@ -533,6 +541,8 @@ class globalSettingsRequestForm(Form): | ||||
|     extra_proxies = FieldList(FormField(SingleExtraProxy), min_entries=5) | ||||
|     extra_browsers = FieldList(FormField(SingleExtraBrowser), min_entries=5) | ||||
|  | ||||
|     default_ua = FormField(DefaultUAInputForm, label="Default User-Agent overrides") | ||||
|  | ||||
|     def validate_extra_proxies(self, extra_validators=None): | ||||
|         for e in self.data['extra_proxies']: | ||||
|             if e.get('proxy_name') or e.get('proxy_url'): | ||||
| @@ -562,6 +572,8 @@ class globalSettingsApplicationForm(commonSettingsForm): | ||||
|     removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|     render_anchor_tag_content = BooleanField('Render anchor tag content', default=False) | ||||
|     shared_diff_access = BooleanField('Allow access to view diff page when password is enabled', default=False, validators=[validators.Optional()]) | ||||
|     rss_hide_muted_watches = BooleanField('Hide muted watches from RSS feed', default=True, | ||||
|                                       validators=[validators.Optional()]) | ||||
|     filter_failure_notification_threshold_attempts = IntegerField('Number of times the filter can be missing before sending a notification', | ||||
|                                                                   render_kw={"style": "width: 5em;"}, | ||||
|                                                                   validators=[validators.NumberRange(min=0, | ||||
|   | ||||
| @@ -3,8 +3,6 @@ from bs4 import BeautifulSoup | ||||
| from inscriptis import get_text | ||||
| from jsonpath_ng.ext import parse | ||||
| from typing import List | ||||
| from inscriptis.css_profiles import CSS_PROFILES, HtmlElement | ||||
| from inscriptis.html_properties import Display | ||||
| from inscriptis.model.config import ParserConfig | ||||
| from xml.sax.saxutils import escape as xml_escape | ||||
| import json | ||||
| @@ -169,14 +167,14 @@ def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=Fals | ||||
|         # And where the matched result doesn't include something that will cause Inscriptis to add a newline | ||||
|         # (This way each 'match' reliably has a new-line in the diff) | ||||
|         # Divs are converted to 4 whitespaces by inscriptis | ||||
|         if append_pretty_line_formatting and len(html_block) and (not hasattr( element, 'tag' ) or not element.tag in (['br', 'hr', 'div', 'p'])): | ||||
|         if append_pretty_line_formatting and len(html_block) and (not hasattr(element, 'tag') or not element.tag in (['br', 'hr', 'div', 'p'])): | ||||
|             html_block += TEXT_FILTER_LIST_LINE_SUFFIX | ||||
|  | ||||
|         if type(element) == etree._ElementStringResult: | ||||
|             html_block += str(element) | ||||
|         elif type(element) == etree._ElementUnicodeResult: | ||||
|             html_block += str(element) | ||||
|         # Some kind of text, UTF-8 or other | ||||
|         if isinstance(element, (str, bytes)): | ||||
|             html_block += element | ||||
|         else: | ||||
|             # Return the HTML which will get parsed as text | ||||
|             html_block += etree.tostring(element, pretty_print=True).decode('utf-8') | ||||
|  | ||||
|     return html_block | ||||
| @@ -196,12 +194,12 @@ def extract_element(find='title', html_content=''): | ||||
|  | ||||
| # | ||||
| def _parse_json(json_data, json_filter): | ||||
|     if 'json:' in json_filter: | ||||
|     if json_filter.startswith("json:"): | ||||
|         jsonpath_expression = parse(json_filter.replace('json:', '')) | ||||
|         match = jsonpath_expression.find(json_data) | ||||
|         return _get_stripped_text_from_json_match(match) | ||||
|  | ||||
|     if 'jq:' in json_filter: | ||||
|     if json_filter.startswith("jq:") or json_filter.startswith("jqraw:"): | ||||
|  | ||||
|         try: | ||||
|             import jq | ||||
| @@ -209,10 +207,15 @@ def _parse_json(json_data, json_filter): | ||||
|             # `jq` requires full compilation in windows and so isn't generally available | ||||
|             raise Exception("jq not support not found") | ||||
|  | ||||
|         jq_expression = jq.compile(json_filter.replace('jq:', '')) | ||||
|         match = jq_expression.input(json_data).all() | ||||
|         if json_filter.startswith("jq:"): | ||||
|             jq_expression = jq.compile(json_filter.removeprefix("jq:")) | ||||
|             match = jq_expression.input(json_data).all() | ||||
|             return _get_stripped_text_from_json_match(match) | ||||
|  | ||||
|         return _get_stripped_text_from_json_match(match) | ||||
|         if json_filter.startswith("jqraw:"): | ||||
|             jq_expression = jq.compile(json_filter.removeprefix("jqraw:")) | ||||
|             match = jq_expression.input(json_data).all() | ||||
|             return '\n'.join(str(item) for item in match) | ||||
|  | ||||
| def _get_stripped_text_from_json_match(match): | ||||
|     s = [] | ||||
|   | ||||
| @@ -5,7 +5,9 @@ from changedetectionio.notification import ( | ||||
|     default_notification_title, | ||||
| ) | ||||
|  | ||||
| # Equal to or greater than this number of FilterNotFoundInResponse exceptions will trigger a filter-not-found notification | ||||
| _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT = 6 | ||||
| DEFAULT_SETTINGS_HEADERS_USERAGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36' | ||||
|  | ||||
| class model(dict): | ||||
|     base_config = { | ||||
| @@ -22,6 +24,10 @@ class model(dict): | ||||
|                     'time_between_check': {'weeks': None, 'days': None, 'hours': 3, 'minutes': None, 'seconds': None}, | ||||
|                     'timeout': int(getenv("DEFAULT_SETTINGS_REQUESTS_TIMEOUT", "45")),  # Default 45 seconds | ||||
|                     'workers': int(getenv("DEFAULT_SETTINGS_REQUESTS_WORKERS", "10")),  # Number of threads, lower is better for slow connections | ||||
|                     'default_ua': { | ||||
|                         'html_requests': getenv("DEFAULT_SETTINGS_HEADERS_USERAGENT", DEFAULT_SETTINGS_HEADERS_USERAGENT), | ||||
|                         'html_webdriver': None, | ||||
|                     } | ||||
|                 }, | ||||
|                 'application': { | ||||
|                     # Custom notification content | ||||
| @@ -41,6 +47,8 @@ class model(dict): | ||||
|                     'pager_size': 50, | ||||
|                     'password': False, | ||||
|                     'render_anchor_tag_content': False, | ||||
|                     'rss_access_token': None, | ||||
|                     'rss_hide_muted_watches': True, | ||||
|                     'schema_version' : 0, | ||||
|                     'shared_diff_access': False, | ||||
|                     'webdriver_delay': None , # Extra delay in seconds before extracting text | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| from distutils.util import strtobool | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from changedetectionio.safe_jinja import render as jinja_render | ||||
|  | ||||
| import os | ||||
| import re | ||||
| import time | ||||
| @@ -10,7 +12,7 @@ from loguru import logger | ||||
| # file:// is further checked by ALLOW_FILE_URI | ||||
| SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):' | ||||
|  | ||||
| minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 60)) | ||||
| minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)) | ||||
| mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7} | ||||
|  | ||||
| from changedetectionio.notification import ( | ||||
| @@ -67,6 +69,7 @@ base_config = { | ||||
|     # Requires setting to None on submit if it's the same as the default | ||||
|     # Should be all None by default, so we use the system default in this case. | ||||
|     'time_between_check': {'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': None}, | ||||
|     'time_between_check_use_default': True, | ||||
|     'title': None, | ||||
|     'trigger_text': [],  # List of text or regex to wait for until a change is detected | ||||
|     'url': '', | ||||
| @@ -137,12 +140,11 @@ class model(dict): | ||||
|  | ||||
|         ready_url = url | ||||
|         if '{%' in url or '{{' in url: | ||||
|             from jinja2 import Environment | ||||
|             # Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/ | ||||
|             jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||
|             try: | ||||
|                 ready_url = str(jinja2_env.from_string(url).render()) | ||||
|                 ready_url = jinja_render(template_str=url) | ||||
|             except Exception as e: | ||||
|                 logger.critical(f"Invalid URL template for: '{url}' - {str(e)}") | ||||
|                 from flask import ( | ||||
|                     flash, Markup, url_for | ||||
|                 ) | ||||
| @@ -236,6 +238,8 @@ class model(dict): | ||||
|  | ||||
|         if len(tmp_history): | ||||
|             self.__newest_history_key = list(tmp_history.keys())[-1] | ||||
|         else: | ||||
|             self.__newest_history_key = None | ||||
|  | ||||
|         self.__history_n = len(tmp_history) | ||||
|  | ||||
| @@ -326,12 +330,9 @@ class model(dict): | ||||
|     def save_history_text(self, contents, timestamp, snapshot_id): | ||||
|         import brotli | ||||
|  | ||||
|         self.ensure_data_dir_exists() | ||||
|         logger.trace(f"{self.get('uuid')} - Updating history.txt with timestamp {timestamp}") | ||||
|  | ||||
|         # Small hack so that we sleep just enough to allow 1 second  between history snapshots | ||||
|         # this is because history.txt indexes/keys snapshots by epoch seconds and we dont want dupe keys | ||||
|         if self.__newest_history_key and int(timestamp) == int(self.__newest_history_key): | ||||
|             time.sleep(timestamp - self.__newest_history_key) | ||||
|         self.ensure_data_dir_exists() | ||||
|  | ||||
|         threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024)) | ||||
|         skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False')) | ||||
| @@ -362,6 +363,7 @@ class model(dict): | ||||
|         # @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status | ||||
|         return snapshot_fname | ||||
|  | ||||
|     @property | ||||
|     @property | ||||
|     def has_empty_checktime(self): | ||||
|         # using all() + dictionary comprehension | ||||
| @@ -523,8 +525,42 @@ class model(dict): | ||||
|         # None is set | ||||
|         return False | ||||
|  | ||||
|     def save_error_text(self, contents): | ||||
|         self.ensure_data_dir_exists() | ||||
|         target_path = os.path.join(self.watch_data_dir, "last-error.txt") | ||||
|         with open(target_path, 'w') as f: | ||||
|             f.write(contents) | ||||
|  | ||||
|     def get_last_fetched_before_filters(self): | ||||
|     def save_xpath_data(self, data, as_error=False): | ||||
|         import json | ||||
|  | ||||
|         if as_error: | ||||
|             target_path = os.path.join(self.watch_data_dir, "elements-error.json") | ||||
|         else: | ||||
|             target_path = os.path.join(self.watch_data_dir, "elements.json") | ||||
|  | ||||
|         self.ensure_data_dir_exists() | ||||
|  | ||||
|         with open(target_path, 'w') as f: | ||||
|             f.write(json.dumps(data)) | ||||
|             f.close() | ||||
|  | ||||
|     # Save as PNG, PNG is larger but better for doing visual diff in the future | ||||
|     def save_screenshot(self, screenshot: bytes, as_error=False): | ||||
|  | ||||
|         if as_error: | ||||
|             target_path = os.path.join(self.watch_data_dir, "last-error-screenshot.png") | ||||
|         else: | ||||
|             target_path = os.path.join(self.watch_data_dir, "last-screenshot.png") | ||||
|  | ||||
|         self.ensure_data_dir_exists() | ||||
|  | ||||
|         with open(target_path, 'wb') as f: | ||||
|             f.write(screenshot) | ||||
|             f.close() | ||||
|  | ||||
|  | ||||
|     def get_last_fetched_text_before_filters(self): | ||||
|         import brotli | ||||
|         filepath = os.path.join(self.watch_data_dir, 'last-fetched.br') | ||||
|  | ||||
| @@ -539,12 +575,56 @@ class model(dict): | ||||
|         with open(filepath, 'rb') as f: | ||||
|             return(brotli.decompress(f.read()).decode('utf-8')) | ||||
|  | ||||
|     def save_last_fetched_before_filters(self, contents): | ||||
|     def save_last_text_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)) | ||||
|  | ||||
|     def save_last_fetched_html(self, timestamp, contents): | ||||
|         import brotli | ||||
|  | ||||
|         self.ensure_data_dir_exists() | ||||
|         snapshot_fname = f"{timestamp}.html.br" | ||||
|         filepath = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|  | ||||
|         with open(filepath, 'wb') as f: | ||||
|             contents = contents.encode('utf-8') if isinstance(contents, str) else contents | ||||
|             try: | ||||
|                 f.write(brotli.compress(contents)) | ||||
|             except Exception as e: | ||||
|                 logger.warning(f"{self.get('uuid')} - Unable to compress snapshot, saving as raw data to {filepath}") | ||||
|                 logger.warning(e) | ||||
|                 f.write(contents) | ||||
|  | ||||
|         self._prune_last_fetched_html_snapshots() | ||||
|  | ||||
|     def get_fetched_html(self, timestamp): | ||||
|         import brotli | ||||
|  | ||||
|         snapshot_fname = f"{timestamp}.html.br" | ||||
|         filepath = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|         if os.path.isfile(filepath): | ||||
|             with open(filepath, 'rb') as f: | ||||
|                 return (brotli.decompress(f.read()).decode('utf-8')) | ||||
|  | ||||
|         return False | ||||
|  | ||||
|  | ||||
|     def _prune_last_fetched_html_snapshots(self): | ||||
|  | ||||
|         dates = list(self.history.keys()) | ||||
|         dates.reverse() | ||||
|  | ||||
|         for index, timestamp in enumerate(dates): | ||||
|             snapshot_fname = f"{timestamp}.html.br" | ||||
|             filepath = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|  | ||||
|             # Keep only the first 2 | ||||
|             if index > 1 and os.path.isfile(filepath): | ||||
|                 os.remove(filepath) | ||||
|  | ||||
|  | ||||
|     @property | ||||
|     def get_browsersteps_available_screenshots(self): | ||||
|         "For knowing which screenshots are available to show the user in BrowserSteps UI" | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import apprise | ||||
| import time | ||||
| from jinja2 import Environment, BaseLoader | ||||
| from apprise import NotifyFormat | ||||
| import json | ||||
| from loguru import logger | ||||
| @@ -49,7 +48,7 @@ from apprise.decorators import notify | ||||
| def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs): | ||||
|     import requests | ||||
|     from apprise.utils import parse_url as apprise_parse_url | ||||
|     from apprise.URLBase import URLBase | ||||
|     from apprise import URLBase | ||||
|  | ||||
|     url = kwargs['meta'].get('url') | ||||
|  | ||||
| @@ -116,16 +115,13 @@ def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs): | ||||
|  | ||||
| def process_notification(n_object, datastore): | ||||
|  | ||||
|     from .safe_jinja import render as jinja_render | ||||
|     now = time.time() | ||||
|     if n_object.get('notification_timestamp'): | ||||
|         logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s") | ||||
|     # Insert variables into the notification content | ||||
|     notification_parameters = create_notification_parameters(n_object, datastore) | ||||
|  | ||||
|     # Get the notification body from datastore | ||||
|     jinja2_env = Environment(loader=BaseLoader) | ||||
|     n_body = jinja2_env.from_string(n_object.get('notification_body', '')).render(**notification_parameters) | ||||
|     n_title = jinja2_env.from_string(n_object.get('notification_title', '')).render(**notification_parameters) | ||||
|     n_format = valid_notification_formats.get( | ||||
|         n_object.get('notification_format', default_notification_format), | ||||
|         valid_notification_formats[default_notification_format], | ||||
| @@ -151,13 +147,18 @@ def process_notification(n_object, datastore): | ||||
|  | ||||
|     with apprise.LogCapture(level=apprise.logging.DEBUG) as logs: | ||||
|         for url in n_object['notification_urls']: | ||||
|  | ||||
|             # Get the notification body from datastore | ||||
|             n_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters) | ||||
|             n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters) | ||||
|  | ||||
|             url = url.strip() | ||||
|             if not url: | ||||
|                 logger.warning(f"Process Notification: skipping empty notification URL.") | ||||
|                 continue | ||||
|  | ||||
|             logger.info(">> Process Notification: AppRise notifying {}".format(url)) | ||||
|             url = jinja2_env.from_string(url).render(**notification_parameters) | ||||
|             url = jinja_render(template_str=url, **notification_parameters) | ||||
|  | ||||
|             # Re 323 - Limit discord length to their 2000 char limit total or it wont send. | ||||
|             # Because different notifications may require different pre-processing, run each sequentially :( | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| from abc import abstractmethod | ||||
| import os | ||||
| import hashlib | ||||
| import re | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from changedetectionio.model import Watch | ||||
| from copy import deepcopy | ||||
| from distutils.util import strtobool | ||||
| from loguru import logger | ||||
| import hashlib | ||||
| import os | ||||
| import re | ||||
|  | ||||
| class difference_detection_processor(): | ||||
|  | ||||
| @@ -21,7 +22,7 @@ class difference_detection_processor(): | ||||
|         self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid)) | ||||
|  | ||||
|     def call_browser(self): | ||||
|  | ||||
|         from requests.structures import CaseInsensitiveDict | ||||
|         # Protect against file:// access | ||||
|         if re.search(r'^file://', self.watch.get('url', '').strip(), re.IGNORECASE): | ||||
|             if not strtobool(os.getenv('ALLOW_FILE_URI', 'false')): | ||||
| @@ -93,7 +94,13 @@ class difference_detection_processor(): | ||||
|             self.fetcher.browser_steps_screenshot_path = os.path.join(self.datastore.datastore_path, self.watch.get('uuid')) | ||||
|  | ||||
|         # Tweak the base config with the per-watch ones | ||||
|         request_headers = self.watch.get('headers', []) | ||||
|         request_headers = CaseInsensitiveDict() | ||||
|  | ||||
|         ua = self.datastore.data['settings']['requests'].get('default_ua') | ||||
|         if ua and ua.get(prefer_fetch_backend): | ||||
|             request_headers.update({'User-Agent': ua.get(prefer_fetch_backend)}) | ||||
|  | ||||
|         request_headers.update(self.watch.get('headers', {})) | ||||
|         request_headers.update(self.datastore.get_all_base_headers()) | ||||
|         request_headers.update(self.datastore.get_all_headers_in_textfile_for_watch(uuid=self.watch.get('uuid'))) | ||||
|  | ||||
| @@ -132,7 +139,7 @@ class difference_detection_processor(): | ||||
|         # After init, call run_changedetection() which will do the actual change-detection | ||||
|  | ||||
|     @abstractmethod | ||||
|     def run_changedetection(self, uuid, skip_when_checksum_same=True): | ||||
|     def run_changedetection(self, watch: Watch, 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() | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
|  | ||||
| from . import difference_detection_processor | ||||
| from copy import deepcopy | ||||
| from loguru import logger | ||||
| import hashlib | ||||
| import urllib3 | ||||
| @@ -20,10 +19,7 @@ class perform_site_check(difference_detection_processor): | ||||
|     screenshot = None | ||||
|     xpath_data = None | ||||
|  | ||||
|     def run_changedetection(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)) | ||||
|     def run_changedetection(self, watch, skip_when_checksum_same=True): | ||||
|  | ||||
|         if not watch: | ||||
|             raise Exception("Watch no longer exists.") | ||||
| @@ -44,13 +40,13 @@ class perform_site_check(difference_detection_processor): | ||||
|             fetched_md5 = hashlib.md5(self.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 self.fetcher.instock_data == 'Possibly in stock' else False | ||||
|             logger.debug(f"Watch UUID {uuid} restock check returned '{self.fetcher.instock_data}' from JS scraper.") | ||||
|             logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned '{self.fetcher.instock_data}' from JS scraper.") | ||||
|         else: | ||||
|             raise UnableToExtractRestockData(status_code=self.fetcher.status_code) | ||||
|  | ||||
|         # The main thing that all this at the moment comes down to :) | ||||
|         changed_detected = False | ||||
|         logger.debug(f"Watch UUID {uuid} restock check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}") | ||||
|         logger.debug(f"Watch UUID {watch.get('uuid')} restock check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}") | ||||
|  | ||||
|         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 | ||||
|   | ||||
| @@ -10,18 +10,18 @@ from . import difference_detection_processor | ||||
| from ..html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text | ||||
| from changedetectionio import html_tools, content_fetchers | ||||
| from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT | ||||
| import changedetectionio.content_fetchers | ||||
| from copy import deepcopy | ||||
| from loguru import logger | ||||
|  | ||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
|  | ||||
| name = 'Webpage Text/HTML, JSON and PDF changes' | ||||
| description = 'Detects all text changes where possible' | ||||
| json_filter_prefixes = ['json:', 'jq:'] | ||||
| json_filter_prefixes = ['json:', 'jq:', 'jqraw:'] | ||||
|  | ||||
| class FilterNotFoundInResponse(ValueError): | ||||
|     def __init__(self, msg): | ||||
|     def __init__(self, msg, screenshot=None, xpath_data=None): | ||||
|         self.screenshot = screenshot | ||||
|         self.xpath_data = xpath_data | ||||
|         ValueError.__init__(self, msg) | ||||
|  | ||||
|  | ||||
| @@ -34,14 +34,12 @@ class PDFToHTMLToolNotFound(ValueError): | ||||
| # (set_proxy_from_list) | ||||
| class perform_site_check(difference_detection_processor): | ||||
|  | ||||
|     def run_changedetection(self, uuid, skip_when_checksum_same=True): | ||||
|     def run_changedetection(self, watch, skip_when_checksum_same=True): | ||||
|         changed_detected = False | ||||
|         html_content = "" | ||||
|         screenshot = False  # as bytes | ||||
|         stripped_text_from_html = "" | ||||
|  | ||||
|         # 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.") | ||||
|  | ||||
| @@ -116,12 +114,12 @@ class perform_site_check(difference_detection_processor): | ||||
|         # Better would be if Watch.model could access the global data also | ||||
|         # and then use getattr https://docs.python.org/3/reference/datamodel.html#object.__getitem__ | ||||
|         # https://realpython.com/inherit-python-dict/ instead of doing it procedurely | ||||
|         include_filters_from_tags = self.datastore.get_tag_overrides_for_watch(uuid=uuid, attr='include_filters') | ||||
|         include_filters_from_tags = self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='include_filters') | ||||
|  | ||||
|         # 1845 - remove duplicated filters in both group and watch include filter | ||||
|         include_filters_rule = list({*watch.get('include_filters', []), *include_filters_from_tags}) | ||||
|         include_filters_rule = list(dict.fromkeys(watch.get('include_filters', []) + include_filters_from_tags)) | ||||
|  | ||||
|         subtractive_selectors = [*self.datastore.get_tag_overrides_for_watch(uuid=uuid, attr='subtractive_selectors'), | ||||
|         subtractive_selectors = [*self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='subtractive_selectors'), | ||||
|                                  *watch.get("subtractive_selectors", []), | ||||
|                                  *self.datastore.data["settings"]["application"].get("global_subtractive_selectors", []) | ||||
|                                  ] | ||||
| @@ -188,7 +186,7 @@ class perform_site_check(difference_detection_processor): | ||||
|                                                                        append_pretty_line_formatting=not watch.is_source_type_url) | ||||
|  | ||||
|                     if not html_content.strip(): | ||||
|                         raise FilterNotFoundInResponse(include_filters_rule) | ||||
|                         raise FilterNotFoundInResponse(msg=include_filters_rule, screenshot=self.fetcher.screenshot, xpath_data=self.fetcher.xpath_data) | ||||
|  | ||||
|                 if has_subtractive_selectors: | ||||
|                     html_content = html_tools.element_removal(subtractive_selectors, html_content) | ||||
| @@ -222,7 +220,7 @@ class perform_site_check(difference_detection_processor): | ||||
|             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(), | ||||
|             rendered_diff = diff.render_diff(previous_version_file_contents=watch.get_last_fetched_text_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), | ||||
| @@ -231,7 +229,7 @@ class perform_site_check(difference_detection_processor): | ||||
|                                              line_feed_sep="\n", | ||||
|                                              include_change_type_prefix=False) | ||||
|  | ||||
|             watch.save_last_fetched_before_filters(text_content_before_ignored_filter) | ||||
|             watch.save_last_text_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 | ||||
| @@ -246,9 +244,10 @@ class perform_site_check(difference_detection_processor): | ||||
|         if not is_json and not empty_pages_are_a_change and len(stripped_text_from_html.strip()) == 0: | ||||
|             raise content_fetchers.exceptions.ReplyWithContentButNoText(url=url, | ||||
|                                                             status_code=self.fetcher.get_last_status_code(), | ||||
|                                                             screenshot=screenshot, | ||||
|                                                             screenshot=self.fetcher.screenshot, | ||||
|                                                             has_filters=has_filter_rule, | ||||
|                                                             html_content=html_content | ||||
|                                                             html_content=html_content, | ||||
|                                                             xpath_data=self.fetcher.xpath_data | ||||
|                                                             ) | ||||
|  | ||||
|         # We rely on the actual text in the html output.. many sites have random script vars etc, | ||||
| @@ -344,17 +343,17 @@ class perform_site_check(difference_detection_processor): | ||||
|                 if not watch['title'] or not len(watch['title']): | ||||
|                     update_obj['title'] = html_tools.extract_element(find='title', html_content=self.fetcher.content) | ||||
|  | ||||
|         logger.debug(f"Watch UUID {uuid} content check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}") | ||||
|         logger.debug(f"Watch UUID {watch.get('uuid')} content check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}") | ||||
|  | ||||
|         if changed_detected: | ||||
|             if watch.get('check_unique_lines', False): | ||||
|                 has_unique_lines = watch.lines_contain_something_unique_compared_to_history(lines=stripped_text_from_html.splitlines()) | ||||
|                 # One or more lines? unsure? | ||||
|                 if not has_unique_lines: | ||||
|                     logger.debug(f"check_unique_lines: UUID {uuid} didnt have anything new setting change_detected=False") | ||||
|                     logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} didnt have anything new setting change_detected=False") | ||||
|                     changed_detected = False | ||||
|                 else: | ||||
|                     logger.debug(f"check_unique_lines: UUID {uuid} had unique content") | ||||
|                     logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} had unique content") | ||||
|  | ||||
|         # Always record the new checksum | ||||
|         update_obj["previous_md5"] = fetched_md5 | ||||
|   | ||||
							
								
								
									
										18
									
								
								changedetectionio/safe_jinja.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								changedetectionio/safe_jinja.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| """ | ||||
| Safe Jinja2 render with max payload sizes | ||||
|  | ||||
| See https://jinja.palletsprojects.com/en/3.1.x/sandbox/#security-considerations | ||||
| """ | ||||
|  | ||||
| import jinja2.sandbox | ||||
| import typing as t | ||||
| import os | ||||
|  | ||||
| JINJA2_MAX_RETURN_PAYLOAD_SIZE = 1024 * int(os.getenv("JINJA2_MAX_RETURN_PAYLOAD_SIZE_KB", 1024 * 10)) | ||||
|  | ||||
|  | ||||
| def render(template_str, **args: t.Any) -> str: | ||||
|     jinja2_env = jinja2.sandbox.ImmutableSandboxedEnvironment(extensions=['jinja2_time.TimeExtension']) | ||||
|     output = jinja2_env.from_string(template_str).render(args) | ||||
|     return output[:JINJA2_MAX_RETURN_PAYLOAD_SIZE] | ||||
|  | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 22 KiB | 
| @@ -1,14 +1,5 @@ | ||||
| $(document).ready(function () { | ||||
|  | ||||
|     // duplicate | ||||
|     var csrftoken = $('input[name=csrf_token]').val(); | ||||
|     $.ajaxSetup({ | ||||
|         beforeSend: function (xhr, settings) { | ||||
|             if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { | ||||
|                 xhr.setRequestHeader("X-CSRFToken", csrftoken) | ||||
|             } | ||||
|         } | ||||
|     }) | ||||
|     var browsersteps_session_id; | ||||
|     var browser_interface_seconds_remaining = 0; | ||||
|     var apply_buttons_disabled = false; | ||||
| @@ -26,7 +17,8 @@ $(document).ready(function () { | ||||
|         set_scale(); | ||||
|     }); | ||||
|     // Should always be disabled | ||||
|     $('#browser_steps >li:first-child select').val('Goto site').attr('disabled', 'disabled'); | ||||
|     $('#browser_steps-0-operation option[value="Goto site"]').prop("selected", "selected"); | ||||
|     $('#browser_steps-0-operation').attr('disabled', 'disabled'); | ||||
|  | ||||
|     $('#browsersteps-click-start').click(function () { | ||||
|         $("#browsersteps-click-start").fadeOut(); | ||||
| @@ -160,6 +152,12 @@ $(document).ready(function () { | ||||
|                     e.offsetX > item.left * y_scale && e.offsetX < item.left * y_scale + item.width * y_scale | ||||
|  | ||||
|                 ) { | ||||
|                     // Ignore really large ones, because we are scraping 'div' also from xpath_element_scraper but | ||||
|                     // that div or whatever could be some wrapper and would generally make you select the whole page | ||||
|                     if (item.width > 800 && item.height > 400) { | ||||
|                         return | ||||
|                     } | ||||
|  | ||||
|                     // 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) | ||||
|                     if (item.width < xpath_data['browser_width']) { | ||||
|   | ||||
							
								
								
									
										10
									
								
								changedetectionio/static/js/csrf.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								changedetectionio/static/js/csrf.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| $(document).ready(function () { | ||||
|     $.ajaxSetup({ | ||||
|         beforeSend: function (xhr, settings) { | ||||
|             if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { | ||||
|                 xhr.setRequestHeader("X-CSRFToken", csrftoken) | ||||
|             } | ||||
|         } | ||||
|     }) | ||||
| }); | ||||
|  | ||||
| @@ -1,12 +1,10 @@ | ||||
| $(document).ready(function () { | ||||
|     var csrftoken = $('input[name=csrf_token]').val(); | ||||
|     $.ajaxSetup({ | ||||
|         beforeSend: function (xhr, settings) { | ||||
|             if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { | ||||
|                 xhr.setRequestHeader("X-CSRFToken", csrftoken) | ||||
|             } | ||||
|     $('.needs-localtime').each(function () { | ||||
|         for (var option of this.options) { | ||||
|             var dateObject = new Date(option.value * 1000); | ||||
|             option.label = dateObject.toLocaleString(undefined, {dateStyle: "full", timeStyle: "medium"}); | ||||
|         } | ||||
|     }) | ||||
|     }); | ||||
|  | ||||
|     // Load it when the #screenshot tab is in use, so we dont give a slow experience when waiting for the text diff to load | ||||
|     window.addEventListener('hashchange', function (e) { | ||||
| @@ -41,6 +39,12 @@ $(document).ready(function () { | ||||
|       $("#highlightSnippet").remove(); | ||||
|     } | ||||
|  | ||||
|     // Listen for Escape key press | ||||
|     window.addEventListener('keydown', function (e) { | ||||
|         if (e.key === 'Escape') { | ||||
|             clean(); | ||||
|         } | ||||
|     }, false); | ||||
|  | ||||
|     function dragTextHandler(event) { | ||||
|         console.log('mouseupped'); | ||||
|   | ||||
| @@ -79,12 +79,7 @@ $(document).ready(function () { | ||||
|         $('#jump-next-diff').click(); | ||||
|     } | ||||
|  | ||||
|     $('.needs-localtime').each(function () { | ||||
|         for (var option of this.options) { | ||||
|             var dateObject = new Date(option.value * 1000); | ||||
|             option.label = dateObject.toLocaleString(undefined, {dateStyle: "full", timeStyle: "medium"}); | ||||
|         } | ||||
|     }) | ||||
|  | ||||
|     onDiffTypeChange( | ||||
|         document.querySelector('#settings [name="diff_type"]:checked'), | ||||
|     ); | ||||
|   | ||||
| @@ -13,30 +13,16 @@ $(document).ready(function() { | ||||
|   $('#send-test-notification').click(function (e) { | ||||
|     e.preventDefault(); | ||||
|  | ||||
|     // this can be global | ||||
|     var csrftoken = $('input[name=csrf_token]').val(); | ||||
|     $.ajaxSetup({ | ||||
|         beforeSend: function(xhr, settings) { | ||||
|             if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { | ||||
|                 xhr.setRequestHeader("X-CSRFToken", csrftoken) | ||||
|             } | ||||
|         } | ||||
|     }) | ||||
|  | ||||
|     data = { | ||||
|       notification_body: $('#notification_body').val(), | ||||
|       notification_format: $('#notification_format').val(), | ||||
|       notification_title: $('#notification_title').val(), | ||||
|       notification_urls: $('.notification-urls').val(), | ||||
|       tags: $('#tags').val(), | ||||
|       window_url: window.location.href, | ||||
|     } | ||||
|  | ||||
|  | ||||
|     if (!data['notification_urls'].length) { | ||||
|       alert("Notification URL list is empty, cannot send test.") | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     $.ajax({ | ||||
|       type: "POST", | ||||
|       url: notification_base_url, | ||||
| @@ -49,7 +35,7 @@ $(document).ready(function() { | ||||
|       } | ||||
|     }).done(function(data){ | ||||
|       console.log(data); | ||||
|       alert('Sent'); | ||||
|       alert(data); | ||||
|     }).fail(function(data){ | ||||
|       console.log(data); | ||||
|       alert('There was an error communicating with the server.'); | ||||
|   | ||||
							
								
								
									
										53
									
								
								changedetectionio/static/js/preview.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								changedetectionio/static/js/preview.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| function redirect_to_version(version) { | ||||
|     var currentUrl = window.location.href; | ||||
|     var baseUrl = currentUrl.split('?')[0]; // Base URL without query parameters | ||||
|     var anchor = ''; | ||||
|  | ||||
|     // Check if there is an anchor | ||||
|     if (baseUrl.indexOf('#') !== -1) { | ||||
|         anchor = baseUrl.substring(baseUrl.indexOf('#')); | ||||
|         baseUrl = baseUrl.substring(0, baseUrl.indexOf('#')); | ||||
|     } | ||||
|     window.location.href = baseUrl + '?version=' + version + anchor; | ||||
| } | ||||
|  | ||||
| document.addEventListener('keydown', function (event) { | ||||
|     var selectElement = document.getElementById('preview-version'); | ||||
|     if (selectElement) { | ||||
|         var selectedOption = selectElement.querySelector('option:checked'); | ||||
|         if (selectedOption) { | ||||
|             if (event.key === 'ArrowLeft') { | ||||
|                 if (selectedOption.previousElementSibling) { | ||||
|                     redirect_to_version(selectedOption.previousElementSibling.value); | ||||
|                 } | ||||
|             } else if (event.key === 'ArrowRight') { | ||||
|                 if (selectedOption.nextElementSibling) { | ||||
|                     redirect_to_version(selectedOption.nextElementSibling.value); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| }); | ||||
|  | ||||
|  | ||||
| document.getElementById('preview-version').addEventListener('change', function () { | ||||
|     redirect_to_version(this.value); | ||||
| }); | ||||
|  | ||||
| var selectElement = document.getElementById('preview-version'); | ||||
| if (selectElement) { | ||||
|     var selectedOption = selectElement.querySelector('option:checked'); | ||||
|     if (selectedOption) { | ||||
|         if (selectedOption.previousElementSibling) { | ||||
|             document.getElementById('btn-previous').href = "?version=" + selectedOption.previousElementSibling.value; | ||||
|         } else { | ||||
|             document.getElementById('btn-previous').remove() | ||||
|         } | ||||
|         if (selectedOption.nextElementSibling) { | ||||
|             document.getElementById('btn-next').href = "?version=" + selectedOption.nextElementSibling.value; | ||||
|         } else { | ||||
|             document.getElementById('btn-next').remove() | ||||
|         } | ||||
|  | ||||
|     } | ||||
| } | ||||
| @@ -2,250 +2,258 @@ | ||||
| // All rights reserved. | ||||
| // yes - this is really a hack, if you are a front-ender and want to help, please get in touch! | ||||
|  | ||||
| $(document).ready(function () { | ||||
| let runInClearMode = false; | ||||
|  | ||||
|     var current_selected_i; | ||||
|     var state_clicked = false; | ||||
| $(document).ready(() => { | ||||
|     let currentSelections = []; | ||||
|     let currentSelection = null; | ||||
|     let appendToList = false; | ||||
|     let c, xctx, ctx; | ||||
|     let xScale = 1, yScale = 1; | ||||
|     let selectorImage, selectorImageRect, selectorData; | ||||
|  | ||||
|     var c; | ||||
|  | ||||
|     // greyed out fill context | ||||
|     var xctx; | ||||
|     // redline highlight context | ||||
|     var ctx; | ||||
|     // Global jQuery selectors with "Elem" appended | ||||
|     const $selectorCanvasElem = $('#selector-canvas'); | ||||
|     const $includeFiltersElem = $("#include_filters"); | ||||
|     const $selectorBackgroundElem = $("img#selector-background"); | ||||
|     const $selectorCurrentXpathElem = $("#selector-current-xpath span"); | ||||
|     const $fetchingUpdateNoticeElem = $('.fetching-update-notice'); | ||||
|     const $selectorWrapperElem = $("#selector-wrapper"); | ||||
|  | ||||
|     var current_default_xpath = []; | ||||
|     var x_scale = 1; | ||||
|     var y_scale = 1; | ||||
|     var selector_image; | ||||
|     var selector_image_rect; | ||||
|     var selector_data; | ||||
|     // Color constants | ||||
|     const FILL_STYLE_HIGHLIGHT = 'rgba(205,0,0,0.35)'; | ||||
|     const FILL_STYLE_GREYED_OUT = 'rgba(205,205,205,0.95)'; | ||||
|     const STROKE_STYLE_HIGHLIGHT = 'rgba(255,0,0, 0.9)'; | ||||
|     const FILL_STYLE_REDLINE = 'rgba(255,0,0, 0.1)'; | ||||
|     const STROKE_STYLE_REDLINE = 'rgba(225,0,0,0.9)'; | ||||
|  | ||||
|     $('#visualselector-tab').click(function () { | ||||
|         $("img#selector-background").off('load'); | ||||
|         state_clicked = false; | ||||
|         current_selected_i = false; | ||||
|         bootstrap_visualselector(); | ||||
|     $('#visualselector-tab').click(() => { | ||||
|         $selectorBackgroundElem.off('load'); | ||||
|         currentSelections = []; | ||||
|         bootstrapVisualSelector(); | ||||
|     }); | ||||
|  | ||||
|     $(document).on('keydown', function (event) { | ||||
|         if ($("img#selector-background").is(":visible")) { | ||||
|             if (event.key == "Escape") { | ||||
|                 state_clicked = false; | ||||
|                 ctx.clearRect(0, 0, c.width, c.height); | ||||
|     function clearReset() { | ||||
|         ctx.clearRect(0, 0, c.width, c.height); | ||||
|  | ||||
|         if ($includeFiltersElem.val().length) { | ||||
|             alert("Existing filters under the 'Filters & Triggers' tab were cleared."); | ||||
|         } | ||||
|         $includeFiltersElem.val(''); | ||||
|  | ||||
|         currentSelections = []; | ||||
|  | ||||
|         // Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector) | ||||
|         runInClearMode = true; | ||||
|  | ||||
|         highlightCurrentSelected(); | ||||
|     } | ||||
|  | ||||
|     function splitToList(v) { | ||||
|         return v.split('\n').map(line => line.trim()).filter(line => line.length > 0); | ||||
|     } | ||||
|  | ||||
|     function sortScrapedElementsBySize() { | ||||
|         // Sort the currentSelections array by area (width * height) in descending order | ||||
|         selectorData['size_pos'].sort((a, b) => { | ||||
|             const areaA = a.width * a.height; | ||||
|             const areaB = b.width * b.height; | ||||
|             return areaB - areaA; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     $(document).on('keydown keyup', (event) => { | ||||
|         if (event.code === 'ShiftLeft' || event.code === 'ShiftRight') { | ||||
|             appendToList = event.type === 'keydown'; | ||||
|         } | ||||
|  | ||||
|         if (event.type === 'keydown') { | ||||
|             if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") { | ||||
|                 clearReset(); | ||||
|             } | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     // For when the page loads | ||||
|     if (!window.location.hash || window.location.hash != '#visualselector') { | ||||
|         $("img#selector-background").attr('src', ''); | ||||
|     $('#clear-selector').on('click', () => { | ||||
|         clearReset(); | ||||
|     }); | ||||
|     // So if they start switching between visualSelector and manual filters, stop it from rendering old filters | ||||
|     $('li.tab a').on('click', () => { | ||||
|         runInClearMode = true; | ||||
|     }); | ||||
|  | ||||
|     if (!window.location.hash || window.location.hash !== '#visualselector') { | ||||
|         $selectorBackgroundElem.attr('src', ''); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     // Handle clearing button/link | ||||
|     $('#clear-selector').on('click', function (event) { | ||||
|         if (!state_clicked) { | ||||
|             alert('Oops, Nothing selected!'); | ||||
|         } | ||||
|         state_clicked = false; | ||||
|         ctx.clearRect(0, 0, c.width, c.height); | ||||
|         xctx.clearRect(0, 0, c.width, c.height); | ||||
|         $("#include_filters").val(''); | ||||
|     }); | ||||
|     bootstrapVisualSelector(); | ||||
|  | ||||
|  | ||||
|     bootstrap_visualselector(); | ||||
|  | ||||
|  | ||||
|     function bootstrap_visualselector() { | ||||
|         if (1) { | ||||
|             // bootstrap it, this will trigger everything else | ||||
|             $("img#selector-background").on("error", function () { | ||||
|                 $('.fetching-update-notice').html("<strong>Ooops!</strong> The VisualSelector tool needs atleast one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page."); | ||||
|                 $('.fetching-update-notice').css('color','#bb0000'); | ||||
|                 $('#selector-current-xpath').hide(); | ||||
|                 $('#clear-selector').hide(); | ||||
|             }).bind('load', function () { | ||||
|     function bootstrapVisualSelector() { | ||||
|         $selectorBackgroundElem | ||||
|             .on("error", () => { | ||||
|                 $fetchingUpdateNoticeElem.html("<strong>Ooops!</strong> The VisualSelector tool needs at least one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page.") | ||||
|                     .css('color', '#bb0000'); | ||||
|                 $('#selector-current-xpath, #clear-selector').hide(); | ||||
|             }) | ||||
|             .on('load', () => { | ||||
|                 console.log("Loaded background..."); | ||||
|                 c = document.getElementById("selector-canvas"); | ||||
|                 // greyed out fill context | ||||
|                 xctx = c.getContext("2d"); | ||||
|                 // redline highlight context | ||||
|                 ctx = c.getContext("2d"); | ||||
|                 if ($("#include_filters").val().trim().length) { | ||||
|                     current_default_xpath = $("#include_filters").val().split(/\r?\n/g); | ||||
|                 } else { | ||||
|                     current_default_xpath = []; | ||||
|                 } | ||||
|                 fetch_data(); | ||||
|                 $('#selector-canvas').off("mousemove mousedown"); | ||||
|                 // screenshot_url defined in the edit.html template | ||||
|             }).attr("src", screenshot_url); | ||||
|         } | ||||
|         // Tell visualSelector that the image should update | ||||
|         var s = $("img#selector-background").attr('src') + "?" + new Date().getTime(); | ||||
|         $("img#selector-background").attr('src', s) | ||||
|                 fetchData(); | ||||
|                 $selectorCanvasElem.off("mousemove mousedown"); | ||||
|             }) | ||||
|             .attr("src", screenshot_url); | ||||
|  | ||||
|         let s = `${$selectorBackgroundElem.attr('src')}?${new Date().getTime()}`; | ||||
|         $selectorBackgroundElem.attr('src', s); | ||||
|     } | ||||
|  | ||||
|     // This is fired once the img src is loaded in bootstrap_visualselector() | ||||
|     function fetch_data() { | ||||
|         // Image is ready | ||||
|         $('.fetching-update-notice').html("Fetching element data.."); | ||||
|     function alertIfFilterNotFound() { | ||||
|         let existingFilters = splitToList($includeFiltersElem.val()); | ||||
|         let sizePosXpaths = selectorData['size_pos'].map(sel => sel.xpath); | ||||
|  | ||||
|         for (let filter of existingFilters) { | ||||
|             if (!sizePosXpaths.includes(filter)) { | ||||
|                 alert(`One or more of your existing filters was not found and will be removed when a new filter is selected.`); | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     function fetchData() { | ||||
|         $fetchingUpdateNoticeElem.html("Fetching element data.."); | ||||
|  | ||||
|         $.ajax({ | ||||
|             url: watch_visual_selector_data_url, | ||||
|             context: document.body | ||||
|         }).done(function (data) { | ||||
|             $('.fetching-update-notice').html("Rendering.."); | ||||
|             selector_data = data; | ||||
|             console.log("Reported browser width from backend: " + data['browser_width']); | ||||
|             state_clicked = false; | ||||
|             set_scale(); | ||||
|             reflow_selector(); | ||||
|             $('.fetching-update-notice').fadeOut(); | ||||
|         }); | ||||
|         }).done((data) => { | ||||
|             $fetchingUpdateNoticeElem.html("Rendering.."); | ||||
|             selectorData = data; | ||||
|             sortScrapedElementsBySize(); | ||||
|             console.log(`Reported browser width from backend: ${data['browser_width']}`); | ||||
|  | ||||
|             // Little sanity check for the user, alert them if something missing | ||||
|             alertIfFilterNotFound(); | ||||
|  | ||||
|             setScale(); | ||||
|             reflowSelector(); | ||||
|             $fetchingUpdateNoticeElem.fadeOut(); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     function updateFiltersText() { | ||||
|         // Assuming currentSelections is already defined and contains the selections | ||||
|         let uniqueSelections = new Set(currentSelections.map(sel => (sel[0] === '/' ? `xpath:${sel.xpath}` : sel.xpath))); | ||||
|  | ||||
|     function set_scale() { | ||||
|  | ||||
|         // some things to check if the scaling doesnt work | ||||
|         // - that the widths/sizes really are about the actual screen size cat elements.json |grep -o width......|sort|uniq | ||||
|         $("#selector-wrapper").show(); | ||||
|         selector_image = $("img#selector-background")[0]; | ||||
|         selector_image_rect = selector_image.getBoundingClientRect(); | ||||
|  | ||||
|         // make the canvas the same size as the image | ||||
|         $('#selector-canvas').attr('height', selector_image_rect.height); | ||||
|         $('#selector-canvas').attr('width', selector_image_rect.width); | ||||
|         $('#selector-wrapper').attr('width', selector_image_rect.width); | ||||
|         x_scale = selector_image_rect.width / selector_data['browser_width']; | ||||
|         y_scale = selector_image_rect.height / selector_image.naturalHeight; | ||||
|         ctx.strokeStyle = 'rgba(255,0,0, 0.9)'; | ||||
|         ctx.fillStyle = 'rgba(255,0,0, 0.1)'; | ||||
|         ctx.lineWidth = 3; | ||||
|         console.log("scaling set  x: " + x_scale + " by y:" + y_scale); | ||||
|         $("#selector-current-xpath").css('max-width', selector_image_rect.width); | ||||
|     } | ||||
|  | ||||
|     function reflow_selector() { | ||||
|         $(window).resize(function () { | ||||
|             set_scale(); | ||||
|             highlight_current_selected_i(); | ||||
|         }); | ||||
|         var selector_currnt_xpath_text = $("#selector-current-xpath span"); | ||||
|  | ||||
|         set_scale(); | ||||
|  | ||||
|         console.log(selector_data['size_pos'].length + " selectors found"); | ||||
|  | ||||
|         // highlight the default one if we can find it in the xPath list | ||||
|         // or the xpath matches the default one | ||||
|         found = false; | ||||
|         if (current_default_xpath.length) { | ||||
|             // Find the first one that matches | ||||
|             // @todo In the future paint all that match | ||||
|             for (const c of current_default_xpath) { | ||||
|                 for (var i = selector_data['size_pos'].length; i !== 0; i--) { | ||||
|                     if (selector_data['size_pos'][i - 1].xpath.trim() === c.trim()) { | ||||
|                         console.log("highlighting " + c); | ||||
|                         current_selected_i = i - 1; | ||||
|                         highlight_current_selected_i(); | ||||
|                         found = true; | ||||
|                         break; | ||||
|                     } | ||||
|                 } | ||||
|                 if (found) { | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|             if (!found) { | ||||
|                 alert("Unfortunately your existing CSS/xPath Filter was no longer found!"); | ||||
|             } | ||||
|         if (currentSelections.length > 0) { | ||||
|             // Convert the Set back to an array and join with newline characters | ||||
|             let textboxFilterText = Array.from(uniqueSelections).join("\n"); | ||||
|             $includeFiltersElem.val(textboxFilterText); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     function setScale() { | ||||
|         $selectorWrapperElem.show(); | ||||
|         selectorImage = $selectorBackgroundElem[0]; | ||||
|         selectorImageRect = selectorImage.getBoundingClientRect(); | ||||
|  | ||||
|         $('#selector-canvas').bind('mousemove', function (e) { | ||||
|             if (state_clicked) { | ||||
|                 return; | ||||
|         $selectorCanvasElem.attr({ | ||||
|             'height': selectorImageRect.height, | ||||
|             'width': selectorImageRect.width | ||||
|         }); | ||||
|         $selectorWrapperElem.attr('width', selectorImageRect.width); | ||||
|         $('#visual-selector-heading').css('max-width', selectorImageRect.width + "px") | ||||
|  | ||||
|         xScale = selectorImageRect.width / selectorImage.naturalWidth; | ||||
|         yScale = selectorImageRect.height / selectorImage.naturalHeight; | ||||
|  | ||||
|         ctx.strokeStyle = STROKE_STYLE_HIGHLIGHT; | ||||
|         ctx.fillStyle = FILL_STYLE_REDLINE; | ||||
|         ctx.lineWidth = 3; | ||||
|         console.log("Scaling set  x: " + xScale + " by y:" + yScale); | ||||
|         $("#selector-current-xpath").css('max-width', selectorImageRect.width); | ||||
|     } | ||||
|  | ||||
|     function reflowSelector() { | ||||
|         $(window).resize(() => { | ||||
|             setScale(); | ||||
|             highlightCurrentSelected(); | ||||
|         }); | ||||
|  | ||||
|         setScale(); | ||||
|  | ||||
|         console.log(selectorData['size_pos'].length + " selectors found"); | ||||
|  | ||||
|         let existingFilters = splitToList($includeFiltersElem.val()); | ||||
|  | ||||
|         selectorData['size_pos'].forEach(sel => { | ||||
|             if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) { | ||||
|                 console.log("highlighting " + c); | ||||
|                 currentSelections.push(sel); | ||||
|             } | ||||
|             ctx.clearRect(0, 0, c.width, c.height); | ||||
|             current_selected_i = null; | ||||
|         }); | ||||
|  | ||||
|             // Add in offset | ||||
|             if ((typeof e.offsetX === "undefined" || typeof e.offsetY === "undefined") || (e.offsetX === 0 && e.offsetY === 0)) { | ||||
|                 var targetOffset = $(e.target).offset(); | ||||
|  | ||||
|         highlightCurrentSelected(); | ||||
|         updateFiltersText(); | ||||
|  | ||||
|         $selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5)); | ||||
|         $selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5)); | ||||
|         $selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5)); | ||||
|  | ||||
|         function handleMouseMove(e) { | ||||
|             if (!e.offsetX && !e.offsetY) { | ||||
|                 const targetOffset = $(e.target).offset(); | ||||
|                 e.offsetX = e.pageX - targetOffset.left; | ||||
|                 e.offsetY = e.pageY - targetOffset.top; | ||||
|             } | ||||
|  | ||||
|             // Reverse order - the most specific one should be deeper/"laster" | ||||
|             // Basically, find the most 'deepest' | ||||
|             var found = 0; | ||||
|             ctx.fillStyle = 'rgba(205,0,0,0.35)'; | ||||
|             // Will be sorted by smallest width*height first | ||||
|             for (var i = 0; i <= selector_data['size_pos'].length; i++) { | ||||
|                 // draw all of them? let them choose somehow? | ||||
|                 var sel = selector_data['size_pos'][i]; | ||||
|                 // If we are in a bounding-box | ||||
|                 if (e.offsetY > sel.top * y_scale && e.offsetY < sel.top * y_scale + sel.height * y_scale | ||||
|                     && | ||||
|                     e.offsetX > sel.left * y_scale && e.offsetX < sel.left * y_scale + sel.width * y_scale | ||||
|             ctx.fillStyle = FILL_STYLE_HIGHLIGHT; | ||||
|  | ||||
|                 ) { | ||||
|  | ||||
|                     // FOUND ONE | ||||
|                     set_current_selected_text(sel.xpath); | ||||
|                     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); | ||||
|  | ||||
|                     // no need to keep digging | ||||
|                     // @todo or, O to go out/up, I to go in | ||||
|                     // or double click to go up/out the selector? | ||||
|                     current_selected_i = i; | ||||
|                     found += 1; | ||||
|                     break; | ||||
|             selectorData['size_pos'].forEach(sel => { | ||||
|                 if (e.offsetY > sel.top * yScale && e.offsetY < sel.top * yScale + sel.height * yScale && | ||||
|                     e.offsetX > sel.left * yScale && e.offsetX < sel.left * yScale + sel.width * yScale) { | ||||
|                     setCurrentSelectedText(sel.xpath); | ||||
|                     drawHighlight(sel); | ||||
|                     currentSelections.push(sel); | ||||
|                     currentSelection = sel; | ||||
|                     highlightCurrentSelected(); | ||||
|                     currentSelections.pop(); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         }.debounce(5)); | ||||
|  | ||||
|         function set_current_selected_text(s) { | ||||
|             selector_currnt_xpath_text[0].innerHTML = s; | ||||
|         } | ||||
|  | ||||
|         function highlight_current_selected_i() { | ||||
|             if (state_clicked) { | ||||
|                 state_clicked = false; | ||||
|                 xctx.clearRect(0, 0, c.width, c.height); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             var sel = selector_data['size_pos'][current_selected_i]; | ||||
|             if (sel[0] == '/') { | ||||
|                 // @todo - not sure just checking / is right | ||||
|                 $("#include_filters").val('xpath:' + sel.xpath); | ||||
|             } else { | ||||
|                 $("#include_filters").val(sel.xpath); | ||||
|             } | ||||
|             xctx.fillStyle = 'rgba(205,205,205,0.95)'; | ||||
|             xctx.strokeStyle = 'rgba(225,0,0,0.9)'; | ||||
|             xctx.lineWidth = 3; | ||||
|             xctx.fillRect(0, 0, c.width, c.height); | ||||
|             // Clear out what only should be seen (make a clear/clean spot) | ||||
|             xctx.clearRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||
|             xctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||
|             state_clicked = true; | ||||
|             set_current_selected_text(sel.xpath); | ||||
|  | ||||
|             }) | ||||
|         } | ||||
|  | ||||
|  | ||||
|         $('#selector-canvas').bind('mousedown', function (e) { | ||||
|             highlight_current_selected_i(); | ||||
|         }); | ||||
|         function setCurrentSelectedText(s) { | ||||
|             $selectorCurrentXpathElem[0].innerHTML = s; | ||||
|         } | ||||
|  | ||||
|         function drawHighlight(sel) { | ||||
|             ctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); | ||||
|             ctx.fillRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); | ||||
|         } | ||||
|  | ||||
|         function handleMouseDown() { | ||||
|             // If we are in 'appendToList' mode, grow the list, if not, just 1 | ||||
|             currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection]; | ||||
|             highlightCurrentSelected(); | ||||
|             updateFiltersText(); | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     function highlightCurrentSelected() { | ||||
|         xctx.fillStyle = FILL_STYLE_GREYED_OUT; | ||||
|         xctx.strokeStyle = STROKE_STYLE_REDLINE; | ||||
|         xctx.lineWidth = 3; | ||||
|         xctx.clearRect(0, 0, c.width, c.height); | ||||
|  | ||||
|         currentSelections.forEach(sel => { | ||||
|             //xctx.clearRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); | ||||
|             xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); | ||||
|         }); | ||||
|     } | ||||
| }); | ||||
| @@ -1,3 +1,17 @@ | ||||
| function toggleOpacity(checkboxSelector, fieldSelector) { | ||||
|     const checkbox = document.querySelector(checkboxSelector); | ||||
|     const fields = document.querySelectorAll(fieldSelector); | ||||
|     function updateOpacity() { | ||||
|         const opacityValue = checkbox.checked ? 0.6 : 1; | ||||
|         fields.forEach(field => { | ||||
|             field.style.opacity = opacityValue; | ||||
|         }); | ||||
|     } | ||||
|     // Initial setup | ||||
|     updateOpacity(); | ||||
|     checkbox.addEventListener('change', updateOpacity); | ||||
| } | ||||
|  | ||||
| $(document).ready(function () { | ||||
|     $('#notification-setting-reset-to-default').click(function (e) { | ||||
|         $('#notification_title').val(''); | ||||
| @@ -10,4 +24,7 @@ $(document).ready(function () { | ||||
|         e.preventDefault(); | ||||
|         $('#notification-tokens-info').toggle(); | ||||
|     }); | ||||
|  | ||||
|     toggleOpacity('#time_between_check_use_default', '#time_between_check'); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -68,7 +68,7 @@ | ||||
|   --color-last-checked: #bbb; | ||||
|   --color-text-footer: #444; | ||||
|   --color-border-watch-table-cell: #eee; | ||||
|   --color-text-watch-tag-list: #e70069; | ||||
|   --color-text-watch-tag-list: rgba(231, 0, 105, 0.4); | ||||
|   --color-background-new-watch-form: rgba(0, 0, 0, 0.05); | ||||
|   --color-background-new-watch-input: var(--color-white); | ||||
|   --color-text-new-watch-input: var(--color-text); | ||||
| @@ -111,7 +111,7 @@ html[data-darkmode="true"] { | ||||
|   --color-background-input: var(--color-grey-350); | ||||
|   --color-text-input-description: var(--color-grey-600); | ||||
|   --color-text-input-placeholder: var(--color-grey-600); | ||||
|   --color-text-watch-tag-list: #fa3e92; | ||||
|   --color-text-watch-tag-list: rgba(250, 62, 146, 0.4); | ||||
|   --color-background-code: var(--color-grey-200); | ||||
|   --color-background-tab: rgba(0, 0, 0, 0.2); | ||||
|   --color-background-tab-hover: rgba(0, 0, 0, 0.5); | ||||
|   | ||||
| @@ -75,7 +75,7 @@ | ||||
|   --color-text-footer: #444; | ||||
|   --color-border-watch-table-cell: #eee; | ||||
|  | ||||
|   --color-text-watch-tag-list: #e70069; | ||||
|   --color-text-watch-tag-list: rgba(231, 0, 105, 0.4); | ||||
|   --color-background-new-watch-form: rgba(0, 0, 0, 0.05); | ||||
|   --color-background-new-watch-input: var(--color-white); | ||||
|   --color-text-new-watch-input: var(--color-text); | ||||
| @@ -127,7 +127,7 @@ html[data-darkmode="true"] { | ||||
|   --color-background-input: var(--color-grey-350); | ||||
|   --color-text-input-description: var(--color-grey-600); | ||||
|   --color-text-input-placeholder: var(--color-grey-600); | ||||
|   --color-text-watch-tag-list: #fa3e92; | ||||
|   --color-text-watch-tag-list: rgba(250, 62, 146, 0.4); | ||||
|   --color-background-code: var(--color-grey-200); | ||||
|  | ||||
|   --color-background-tab: rgba(0, 0, 0, 0.2); | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
|  | ||||
| #selector-wrapper { | ||||
|   height: 100%; | ||||
|   text-align: center; | ||||
|    | ||||
|   max-height: 70vh; | ||||
|   overflow-y: scroll; | ||||
|   position: relative; | ||||
|   | ||||
| @@ -187,8 +187,11 @@ code { | ||||
| } | ||||
|  | ||||
| .watch-tag-list { | ||||
|   color: var(--color-text-watch-tag-list); | ||||
|   color: var(--color-white); | ||||
|   white-space: nowrap; | ||||
|   background: var(--color-text-watch-tag-list); | ||||
|   border-radius: 5px; | ||||
|   padding: 2px 5px; | ||||
| } | ||||
|  | ||||
| .box { | ||||
| @@ -240,7 +243,6 @@ body::after { | ||||
| body::before { | ||||
|   // background-image set in base.html so it works with reverse proxies etc | ||||
|   content: ""; | ||||
|   background-size: cover | ||||
| } | ||||
|  | ||||
| body:after, | ||||
| @@ -669,14 +671,25 @@ footer { | ||||
|   and also iPads specifically. | ||||
|   */ | ||||
|   .watch-table { | ||||
|     /* make headings work on mobile */ | ||||
|     thead { | ||||
|       display: block; | ||||
|       tr { | ||||
|         th { | ||||
|           display: inline-block; | ||||
|         } | ||||
|       } | ||||
|       .empty-cell { | ||||
|         display: none; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     /* Force table to not be like tables anymore */ | ||||
|     thead, | ||||
|     tbody, | ||||
|     th, | ||||
|     td, | ||||
|     tr { | ||||
|       display: block; | ||||
|     tbody { | ||||
|       td, | ||||
|       tr { | ||||
|         display: block; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .last-checked { | ||||
| @@ -700,13 +713,6 @@ footer { | ||||
|       display: inline-block; | ||||
|     } | ||||
|  | ||||
|     /* Hide table headers (but not display: none;, for accessibility) */ | ||||
|     thead tr { | ||||
|       position: absolute; | ||||
|       top: -9999px; | ||||
|       left: -9999px; | ||||
|     } | ||||
|  | ||||
|     .pure-table td, | ||||
|     .pure-table th { | ||||
|       border: none; | ||||
| @@ -751,6 +757,7 @@ footer { | ||||
|   thead { | ||||
|     background-color: var(--color-background-table-thead); | ||||
|     color: var(--color-text); | ||||
|     border-bottom: 1px solid var(--color-background-table-thead); | ||||
|   } | ||||
|  | ||||
|   td, | ||||
| @@ -925,23 +932,26 @@ 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 { | ||||
| } | ||||
|  | ||||
| .border-fieldset { | ||||
|   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; | ||||
|       .pure-control-group { | ||||
|         padding-bottom: 0; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| ul { | ||||
|   padding-left: 1em; | ||||
|   padding-top: 0px; | ||||
| @@ -1016,6 +1026,11 @@ ul { | ||||
|   border-radius: 10px; | ||||
|   margin-bottom: 1em; | ||||
|   display: none; | ||||
|   button { | ||||
|     /* some space if they wrap the page */ | ||||
|     margin-bottom: 3px; | ||||
|     margin-top: 3px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .checkbox-uuid { | ||||
| @@ -1077,6 +1092,9 @@ ul { | ||||
|     li { | ||||
|       list-style: none; | ||||
|       font-size: 0.8rem; | ||||
|       > * { | ||||
|         display: inline-block; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -1096,3 +1114,16 @@ ul { | ||||
|   white-space: nowrap; | ||||
| } | ||||
|  | ||||
| #chrome-extension-link { | ||||
|   img { | ||||
|     height: 21px; | ||||
|     padding: 2px; | ||||
|     vertical-align: middle; | ||||
|   } | ||||
|  | ||||
|   padding: 9px; | ||||
|   border: 1px solid var(--color-grey-800); | ||||
|   border-radius: 10px; | ||||
|   vertical-align: middle; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -284,7 +284,7 @@ ul#requests-extra_browsers { | ||||
|   --color-last-checked: #bbb; | ||||
|   --color-text-footer: #444; | ||||
|   --color-border-watch-table-cell: #eee; | ||||
|   --color-text-watch-tag-list: #e70069; | ||||
|   --color-text-watch-tag-list: rgba(231, 0, 105, 0.4); | ||||
|   --color-background-new-watch-form: rgba(0, 0, 0, 0.05); | ||||
|   --color-background-new-watch-input: var(--color-white); | ||||
|   --color-text-new-watch-input: var(--color-text); | ||||
| @@ -327,7 +327,7 @@ html[data-darkmode="true"] { | ||||
|   --color-background-input: var(--color-grey-350); | ||||
|   --color-text-input-description: var(--color-grey-600); | ||||
|   --color-text-input-placeholder: var(--color-grey-600); | ||||
|   --color-text-watch-tag-list: #fa3e92; | ||||
|   --color-text-watch-tag-list: rgba(250, 62, 146, 0.4); | ||||
|   --color-background-code: var(--color-grey-200); | ||||
|   --color-background-tab: rgba(0, 0, 0, 0.2); | ||||
|   --color-background-tab-hover: rgba(0, 0, 0, 0.5); | ||||
| @@ -532,8 +532,11 @@ code { | ||||
|     margin: 0 3px 0 5px; } | ||||
|  | ||||
| .watch-tag-list { | ||||
|   color: var(--color-text-watch-tag-list); | ||||
|   white-space: nowrap; } | ||||
|   color: var(--color-white); | ||||
|   white-space: nowrap; | ||||
|   background: var(--color-text-watch-tag-list); | ||||
|   border-radius: 5px; | ||||
|   padding: 2px 5px; } | ||||
|  | ||||
| .box { | ||||
|   max-width: 80%; | ||||
| @@ -571,8 +574,7 @@ body::after { | ||||
|   opacity: 0.91; } | ||||
|  | ||||
| body::before { | ||||
|   content: ""; | ||||
|   background-size: cover; } | ||||
|   content: ""; } | ||||
|  | ||||
| body:after, | ||||
| body:before { | ||||
| @@ -861,14 +863,17 @@ footer { | ||||
|   and also iPads specifically. | ||||
|   */ | ||||
|   .watch-table { | ||||
|     /* make headings work on mobile */ | ||||
|     /* Force table to not be like tables anymore */ | ||||
|     /* Force table to not be like tables anymore */ | ||||
|     /* Hide table headers (but not display: none;, for accessibility) */ } | ||||
|     .watch-table thead, | ||||
|     .watch-table tbody, | ||||
|     .watch-table th, | ||||
|     .watch-table td, | ||||
|     .watch-table tr { | ||||
|     /* Force table to not be like tables anymore */ } | ||||
|     .watch-table thead { | ||||
|       display: block; } | ||||
|       .watch-table thead tr th { | ||||
|         display: inline-block; } | ||||
|       .watch-table thead .empty-cell { | ||||
|         display: none; } | ||||
|     .watch-table tbody td, | ||||
|     .watch-table tbody tr { | ||||
|       display: block; } | ||||
|     .watch-table .last-checked > span { | ||||
|       vertical-align: middle; } | ||||
| @@ -880,10 +885,6 @@ footer { | ||||
|       content: "Last Changed "; } | ||||
|     .watch-table td.inline { | ||||
|       display: inline-block; } | ||||
|     .watch-table thead tr { | ||||
|       position: absolute; | ||||
|       top: -9999px; | ||||
|       left: -9999px; } | ||||
|     .watch-table .pure-table td, | ||||
|     .watch-table .pure-table th { | ||||
|       border: none; } | ||||
| @@ -910,7 +911,8 @@ footer { | ||||
|   border-color: var(--color-border-table-cell); } | ||||
|   .pure-table thead { | ||||
|     background-color: var(--color-background-table-thead); | ||||
|     color: var(--color-text); } | ||||
|     color: var(--color-text); | ||||
|     border-bottom: 1px solid var(--color-background-table-thead); } | ||||
|   .pure-table td, | ||||
|   .pure-table th { | ||||
|     border-left-color: var(--color-border-table-cell); } | ||||
| @@ -1038,17 +1040,18 @@ 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 { | ||||
|  | ||||
| .border-fieldset { | ||||
|   border: 1px solid #ccc; | ||||
|   padding: 1rem; | ||||
|   border-radius: 5px; | ||||
|   margin-bottom: 1rem; } | ||||
|   .border-fieldset h3 { | ||||
|     margin-top: 0; } | ||||
|   .border-fieldset fieldset:last-of-type { | ||||
|     padding-bottom: 0; } | ||||
|     .border-fieldset fieldset:last-of-type .pure-control-group { | ||||
|       padding-bottom: 0; } | ||||
|       .edit-form .text-filtering fieldset:last-of-type .pure-control-group { | ||||
|         padding-bottom: 0; } | ||||
|  | ||||
| ul { | ||||
|   padding-left: 1em; | ||||
| @@ -1062,6 +1065,7 @@ ul { | ||||
|  | ||||
| #selector-wrapper { | ||||
|   height: 100%; | ||||
|   text-align: center; | ||||
|   max-height: 70vh; | ||||
|   overflow-y: scroll; | ||||
|   position: relative; } | ||||
| @@ -1124,6 +1128,10 @@ ul { | ||||
|   border-radius: 10px; | ||||
|   margin-bottom: 1em; | ||||
|   display: none; } | ||||
|   #checkbox-operations button { | ||||
|     /* some space if they wrap the page */ | ||||
|     margin-bottom: 3px; | ||||
|     margin-top: 3px; } | ||||
|  | ||||
| .checkbox-uuid > * { | ||||
|   vertical-align: middle; } | ||||
| @@ -1169,6 +1177,8 @@ ul { | ||||
|     #quick-watch-processor-type ul li { | ||||
|       list-style: none; | ||||
|       font-size: 0.8rem; } | ||||
|       #quick-watch-processor-type ul li > * { | ||||
|         display: inline-block; } | ||||
|  | ||||
| .restock-label { | ||||
|   padding: 3px; | ||||
| @@ -1180,3 +1190,13 @@ ul { | ||||
|   .restock-label.not-in-stock { | ||||
|     background-color: var(--color-background-button-cancel); | ||||
|     color: #777; } | ||||
|  | ||||
| #chrome-extension-link { | ||||
|   padding: 9px; | ||||
|   border: 1px solid var(--color-grey-800); | ||||
|   border-radius: 10px; | ||||
|   vertical-align: middle; } | ||||
|   #chrome-extension-link img { | ||||
|     height: 21px; | ||||
|     padding: 2px; | ||||
|     vertical-align: middle; } | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| from distutils.util import strtobool | ||||
| from changedetectionio.strtobool import strtobool | ||||
|  | ||||
| from flask import ( | ||||
|     flash | ||||
| @@ -124,12 +124,12 @@ class ChangeDetectionStore: | ||||
|                 self.__data['app_guid'] = str(uuid_builder.uuid4()) | ||||
|  | ||||
|         # Generate the URL access token for RSS feeds | ||||
|         if not 'rss_access_token' in self.__data['settings']['application']: | ||||
|         if not self.__data['settings']['application'].get('rss_access_token'): | ||||
|             secret = secrets.token_hex(16) | ||||
|             self.__data['settings']['application']['rss_access_token'] = secret | ||||
|  | ||||
|         # Generate the API access token | ||||
|         if not 'api_access_token' in self.__data['settings']['application']: | ||||
|         if not self.__data['settings']['application'].get('api_access_token'): | ||||
|             secret = secrets.token_hex(16) | ||||
|             self.__data['settings']['application']['api_access_token'] = secret | ||||
|  | ||||
| @@ -163,7 +163,6 @@ class ChangeDetectionStore: | ||||
|                         del (update_obj[dict_key]) | ||||
|  | ||||
|             self.__data['watching'][uuid].update(update_obj) | ||||
|  | ||||
|         self.needs_write = True | ||||
|  | ||||
|     @property | ||||
| @@ -178,7 +177,7 @@ class ChangeDetectionStore: | ||||
|     @property | ||||
|     def has_unviewed(self): | ||||
|         for uuid, watch in self.__data['watching'].items(): | ||||
|             if watch.viewed == False: | ||||
|             if watch.history_n >= 2 and watch.viewed == False: | ||||
|                 return True | ||||
|         return False | ||||
|  | ||||
| @@ -243,6 +242,14 @@ class ChangeDetectionStore: | ||||
|     def clear_watch_history(self, uuid): | ||||
|         import pathlib | ||||
|  | ||||
|         # JSON Data, Screenshots, Textfiles (history index and snapshots), HTML in the future etc | ||||
|         for item in pathlib.Path(os.path.join(self.datastore_path, uuid)).rglob("*.*"): | ||||
|             unlink(item) | ||||
|  | ||||
|         # Force the attr to recalculate | ||||
|         bump = self.__data['watching'][uuid].history | ||||
|  | ||||
|         # Do this last because it will trigger a recheck due to last_checked being zero | ||||
|         self.__data['watching'][uuid].update({ | ||||
|                 'browser_steps_last_error_step' : None, | ||||
|                 'check_count': 0, | ||||
| @@ -259,13 +266,6 @@ class ChangeDetectionStore: | ||||
|                 'track_ldjson_price_data': None, | ||||
|             }) | ||||
|  | ||||
|         # JSON Data, Screenshots, Textfiles (history index and snapshots), HTML in the future etc | ||||
|         for item in pathlib.Path(os.path.join(self.datastore_path, uuid)).rglob("*.*"): | ||||
|             unlink(item) | ||||
|  | ||||
|         # Force the attr to recalculate | ||||
|         bump = self.__data['watching'][uuid].history | ||||
|  | ||||
|         self.needs_write_urgent = True | ||||
|  | ||||
|     def add_watch(self, url, tag='', extras=None, tag_uuids=None, write_to_disk_now=True): | ||||
| @@ -376,46 +376,6 @@ class ChangeDetectionStore: | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     # Save as PNG, PNG is larger but better for doing visual diff in the future | ||||
|     def save_screenshot(self, watch_uuid, screenshot: bytes, as_error=False): | ||||
|         if not self.data['watching'].get(watch_uuid): | ||||
|             return | ||||
|  | ||||
|         if as_error: | ||||
|             target_path = os.path.join(self.datastore_path, watch_uuid, "last-error-screenshot.png") | ||||
|         else: | ||||
|             target_path = os.path.join(self.datastore_path, watch_uuid, "last-screenshot.png") | ||||
|  | ||||
|         self.data['watching'][watch_uuid].ensure_data_dir_exists() | ||||
|  | ||||
|         with open(target_path, 'wb') as f: | ||||
|             f.write(screenshot) | ||||
|             f.close() | ||||
|  | ||||
|  | ||||
|     def save_error_text(self, watch_uuid, contents): | ||||
|         if not self.data['watching'].get(watch_uuid): | ||||
|             return | ||||
|  | ||||
|         self.data['watching'][watch_uuid].ensure_data_dir_exists() | ||||
|         target_path = os.path.join(self.datastore_path, watch_uuid, "last-error.txt") | ||||
|         with open(target_path, 'w') as f: | ||||
|             f.write(contents) | ||||
|  | ||||
|     def save_xpath_data(self, watch_uuid, data, as_error=False): | ||||
|  | ||||
|         if not self.data['watching'].get(watch_uuid): | ||||
|             return | ||||
|         if as_error: | ||||
|             target_path = os.path.join(self.datastore_path, watch_uuid, "elements-error.json") | ||||
|         else: | ||||
|             target_path = os.path.join(self.datastore_path, watch_uuid, "elements.json") | ||||
|         self.data['watching'][watch_uuid].ensure_data_dir_exists() | ||||
|         with open(target_path, 'w') as f: | ||||
|             f.write(json.dumps(data)) | ||||
|             f.close() | ||||
|  | ||||
|  | ||||
|     def sync_to_json(self): | ||||
|         logger.info("Saving JSON..") | ||||
|         try: | ||||
| @@ -554,7 +514,6 @@ class ChangeDetectionStore: | ||||
|         return os.path.isfile(filepath) | ||||
|  | ||||
|     def get_all_base_headers(self): | ||||
|         from .model.App import parse_headers_from_text_file | ||||
|         headers = {} | ||||
|         # Global app settings | ||||
|         headers.update(self.data['settings'].get('headers', {})) | ||||
| @@ -657,7 +616,10 @@ class ChangeDetectionStore: | ||||
|         return res | ||||
|  | ||||
|     def tag_exists_by_name(self, tag_name): | ||||
|         return any(v.get('title', '').lower() == tag_name.lower() for k, v in self.__data['settings']['application']['tags'].items()) | ||||
|         # Check if any tag dictionary has a 'title' attribute matching the provided tag_name | ||||
|         tags = self.__data['settings']['application']['tags'].values() | ||||
|         return next((v for v in tags if v.get('title', '').lower() == tag_name.lower()), | ||||
|                     None) | ||||
|  | ||||
|     def get_updates_available(self): | ||||
|         import inspect | ||||
| @@ -869,3 +831,21 @@ class ChangeDetectionStore: | ||||
|                         self.__data["watching"][awatch]['include_filters'][num] = 'xpath1:' + selector | ||||
|                     if selector.startswith('xpath:'): | ||||
|                         self.__data["watching"][awatch]['include_filters'][num] = selector.replace('xpath:', 'xpath1:', 1) | ||||
|  | ||||
|     # Use more obvious default time setting | ||||
|     def update_15(self): | ||||
|         for uuid in self.__data["watching"]: | ||||
|             if self.__data["watching"][uuid]['time_between_check'] == self.__data['settings']['requests']['time_between_check']: | ||||
|                 # What the old logic was, which was pretty confusing | ||||
|                 self.__data["watching"][uuid]['time_between_check_use_default'] = True | ||||
|             elif all(value is None or value == 0 for value in self.__data["watching"][uuid]['time_between_check'].values()): | ||||
|                 self.__data["watching"][uuid]['time_between_check_use_default'] = True | ||||
|             else: | ||||
|                 # Something custom here | ||||
|                 self.__data["watching"][uuid]['time_between_check_use_default'] = False | ||||
|  | ||||
|     # Correctly set datatype for older installs where 'tag' was string and update_12 did not catch it | ||||
|     def update_16(self): | ||||
|         for uuid, watch in self.data['watching'].items(): | ||||
|             if isinstance(watch.get('tags'), str): | ||||
|                 self.data['watching'][uuid]['tags'] = [] | ||||
|   | ||||
							
								
								
									
										23
									
								
								changedetectionio/strtobool.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								changedetectionio/strtobool.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| # Because strtobool was removed in python 3.12 distutils | ||||
|  | ||||
| _MAP = { | ||||
|     'y': True, | ||||
|     'yes': True, | ||||
|     't': True, | ||||
|     'true': True, | ||||
|     'on': True, | ||||
|     '1': True, | ||||
|     'n': False, | ||||
|     'no': False, | ||||
|     'f': False, | ||||
|     'false': False, | ||||
|     'off': False, | ||||
|     '0': False | ||||
| } | ||||
|  | ||||
|  | ||||
| def strtobool(value): | ||||
|     try: | ||||
|         return _MAP[str(value).lower()] | ||||
|     except KeyError: | ||||
|         raise ValueError('"{}" is not a valid bool value'.format(value)) | ||||
							
								
								
									
										6
									
								
								changedetectionio/templates/IMPORTANT.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								changedetectionio/templates/IMPORTANT.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| # Important notes about templates | ||||
|  | ||||
| Template names should always end in ".html", ".htm", ".xml", ".xhtml", ".svg", even the `import`'ed templates. | ||||
|  | ||||
| Jinja2's `def select_jinja_autoescape(self, filename: str) -> bool:` will check the filename extension and enable autoescaping | ||||
|  | ||||
| @@ -1,5 +1,5 @@ | ||||
| 
 | ||||
| {% from '_helpers.jinja' import render_field %} | ||||
| {% from '_helpers.html' import render_field %} | ||||
| 
 | ||||
| {% macro render_common_settings_form(form, emailprefix, settings_application) %} | ||||
|                         <div class="pure-control-group"> | ||||
| @@ -6,7 +6,9 @@ | ||||
|     <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)}}" > | ||||
|     {% if app_rss_token %} | ||||
|       <link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag_uuid %}- {{active_tag.title}}{% endif %}" href="{{ url_for('rss', tag=active_tag_uuid , token=app_rss_token)}}" > | ||||
|     {% endif %} | ||||
|     <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')}}?v={{ get_css_version() }}" > | ||||
|     {% if extra_stylesheets %} | ||||
| @@ -24,13 +26,11 @@ | ||||
|     <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> | ||||
|         const csrftoken="{{ csrf_token() }}"; | ||||
|     </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='csrf.js')}}" defer></script> | ||||
|   </head> | ||||
|  | ||||
|   <body> | ||||
| @@ -89,8 +89,8 @@ | ||||
|           <li class="pure-menu-item pure-form" id="search-menu-item"> | ||||
|             <!-- We use GET here so it offers people a chance to set bookmarks etc --> | ||||
|             <form name="searchForm" action="" method="GET"> | ||||
|               <input id="search-q" class="" name="q" placeholder="URL or Title {% if active_tag %}in '{{ active_tag }}'{% endif %}" required="" type="text" value=""> | ||||
|               <input name="tags" type="hidden" value="{% if active_tag %}{{active_tag}}{% endif %}"> | ||||
|               <input id="search-q" class="" name="q" placeholder="URL or Title {% if active_tag_uuid %}in '{{ active_tag.title }}'{% endif %}" required="" type="text" value=""> | ||||
|               <input name="tags" type="hidden" value="{% if active_tag_uuid %}{{active_tag_uuid}}{% endif %}"> | ||||
|               <button class="toggle-button " id="toggle-search" type="button" title="Search, or Use Alt+S Key" > | ||||
|                 {% include "svgs/search-icon.svg" %} | ||||
|               </button> | ||||
| @@ -147,7 +147,19 @@ | ||||
|     <section class="content"> | ||||
|         <div id="overlay"> | ||||
|             <div class="content"> | ||||
|                 <strong>changedetection.io needs your support!</strong><br> | ||||
|                 <h4>Try our Chrome extension</h4> | ||||
|                 <p> | ||||
|                     <a id="chrome-extension-link" | ||||
|                        title="Try our new Chrome Extension!" | ||||
|                        href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop"> | ||||
|                         <img src="{{url_for('static_content', group='images', filename='Google-Chrome-icon.png')}}"> | ||||
|                         Chrome Webstore | ||||
|                     </a> | ||||
|                 </p> | ||||
|  | ||||
|                 Easily add the current web-page from your browser directly into your changedetection.io tool, more great features coming soon! | ||||
|  | ||||
|                 <h4>Changedetection.io needs your support!</h4> | ||||
|                 <p> | ||||
|                     You can help us by supporting changedetection.io on these platforms; | ||||
|                 </p> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_helpers.html' import render_field, render_checkbox_field, render_button %} | ||||
| {% block content %} | ||||
| <script> | ||||
|     const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}"; | ||||
|   | ||||
| @@ -1,13 +1,14 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_common_fields.jinja' import render_common_settings_form %} | ||||
| {% from '_helpers.html' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_common_fields.html' import render_common_settings_form %} | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script> | ||||
| <script> | ||||
|     const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}'); | ||||
|     const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}'); | ||||
|     const browser_steps_fetch_screenshot_image_url="{{url_for('browser_steps.browser_steps_fetch_screenshot_image', uuid=uuid)}}"; | ||||
|     <!-- Should be _external so that firefox and others load it more reliably --> | ||||
|     const browser_steps_fetch_screenshot_image_url="{{url_for('browser_steps.browser_steps_fetch_screenshot_image', uuid=uuid, _external=True)}}"; | ||||
|     const browser_steps_last_error_step={{ watch.browser_steps_last_error_step|tojson }}; | ||||
|     const browser_steps_start_url="{{url_for('browser_steps.browsersteps_start_session', uuid=uuid)}}"; | ||||
|     const browser_steps_sync_url="{{url_for('browser_steps.browsersteps_ui_update', uuid=uuid)}}"; | ||||
| @@ -31,6 +32,7 @@ | ||||
| <script src="{{url_for('static_content', group='js', filename='browser-steps.js')}}" defer></script> | ||||
| {% endif %} | ||||
|  | ||||
| {% set has_tag_filters_extra="WARNING: Watch has tag/groups set with special filters\n" if has_special_tag_options else '' %} | ||||
| <script src="{{url_for('static_content', group='js', filename='recheck-proxy.js')}}" defer></script> | ||||
|  | ||||
| <div class="edit-form monospaced-textarea"> | ||||
| @@ -85,15 +87,9 @@ | ||||
|                         {{ render_field(form.tags) }} | ||||
|                         <span class="pure-form-message-inline">Organisational tag/group name used in the main listing page</span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                     <div class="pure-control-group time-between-check border-fieldset"> | ||||
|                         {{ render_field(form.time_between_check, class="time-check-widget") }} | ||||
|                         {% if has_empty_checktime %} | ||||
|                         <span class="pure-form-message-inline">Currently using the <a | ||||
|                                 href="{{ url_for('settings_page', uuid=uuid) }}">default global settings</a>, change to another value if you want to be specific.</span> | ||||
|                         {% else %} | ||||
|                         <span class="pure-form-message-inline">Set to blank to use the <a | ||||
|                                 href="{{ url_for('settings_page', uuid=uuid) }}">default global settings</a>.</span> | ||||
|                         {% endif %} | ||||
|                         {{ render_checkbox_field(form.time_between_check_use_default, class="use-default-timecheck") }} | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.extract_title_as_title) }} | ||||
| @@ -280,7 +276,7 @@ User-Agent: wonderbra 1.0") }} | ||||
|                     <div class="pure-control-group"> | ||||
|                         {% set field = render_field(form.include_filters, | ||||
|                             rows=5, | ||||
|                             placeholder="#example | ||||
|                             placeholder=has_tag_filters_extra+"#example | ||||
| xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                             class="m-d") | ||||
|                         %} | ||||
| @@ -296,7 +292,7 @@ xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                             <ul> | ||||
|                                 <li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required,  <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li> | ||||
|                                 {% if jq_support %} | ||||
|                                 <li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>.</li> | ||||
|                                 <li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>. Prefix <code>jqraw:</code> outputs the results as text instead of a JSON list.</li> | ||||
|                                 {% else %} | ||||
|                                 <li>jq support not installed</li> | ||||
|                                 {% endif %} | ||||
| @@ -316,18 +312,19 @@ xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                 </span> | ||||
|                     </div> | ||||
|                 <fieldset class="pure-control-group"> | ||||
|                     {{ render_field(form.subtractive_selectors, rows=5, placeholder="header | ||||
|                     {{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header | ||||
| footer | ||||
| nav | ||||
| .stockticker") }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                           <li> Remove HTML element(s) by CSS selector before text conversion. </li> | ||||
|                           <li> Don't paste HTML here, use only CSS selectors </li> | ||||
|                           <li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li> | ||||
|                         </ul> | ||||
|                       </span> | ||||
|                 </fieldset> | ||||
|                 <div class="text-filtering"> | ||||
|                 <div class="text-filtering border-fieldset"> | ||||
|                 <fieldset class="pure-group" id="text-filtering-type-options"> | ||||
|                     <h3>Text filtering</h3> | ||||
|                         Limit trigger/ignore/block/extract to;<br> | ||||
| @@ -435,8 +432,8 @@ Unavailable") }} | ||||
|                 <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> | ||||
|                             <span class="pure-form-message-inline" id="visual-selector-heading"> | ||||
|                                 The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection. It automatically fills-in the filters in the "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab. Use <strong>Shift+Click</strong> to select multiple items. | ||||
|                             </span> | ||||
|  | ||||
|                             <div id="selector-header"> | ||||
| @@ -484,13 +481,17 @@ Unavailable") }} | ||||
|                             <td>{{ "{:,}".format(watch.history|length) }}</td> | ||||
|                         </tr> | ||||
|                         <tr> | ||||
|                             <td>Last fetch time</td> | ||||
|                             <td>Last fetch duration</td> | ||||
|                             <td>{{ watch.fetch_time }}s</td> | ||||
|                         </tr> | ||||
|                         <tr> | ||||
|                             <td>Notification alert count</td> | ||||
|                             <td>{{ watch.notification_alert_count }}</td> | ||||
|                         </tr> | ||||
|                         <tr> | ||||
|                             <td>Server type reply</td> | ||||
|                             <td>{{ watch.get('remote_server_reply') }}</td> | ||||
|                         </tr> | ||||
|                         </tbody> | ||||
|                     </table> | ||||
|                 </div> | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_field %} | ||||
| {% from '_helpers.html' import render_field %} | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <div class="edit-form monospaced-textarea"> | ||||
|  | ||||
| @@ -107,7 +107,7 @@ | ||||
|                                     <option value="" style="color: #aaa"> -- none --</option> | ||||
|                                     <option value="url">URL</option> | ||||
|                                     <option value="title">Title</option> | ||||
|                                     <option value="include_filter">CSS/xPath filter</option> | ||||
|                                     <option value="include_filters">CSS/xPath filter</option> | ||||
|                                     <option value="tag">Group / Tag name(s)</option> | ||||
|                                     <option value="interval_minutes">Recheck time (minutes)</option> | ||||
|                                 </select></td> | ||||
|   | ||||
| @@ -1,72 +1,103 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| <script> | ||||
|     const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}"; | ||||
|     {% if last_error_screenshot %} | ||||
|     const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||
|     {% endif %} | ||||
|     const highlight_submit_ignore_url="{{url_for('highlight_submit_ignore_url', uuid=uuid)}}"; | ||||
| </script> | ||||
| <script src="{{url_for('static_content', group='js', filename='diff-overview.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 %} | ||||
|         {% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">Error Screenshot</a></li> {% endif %} | ||||
|         {% if history_n > 0 %} | ||||
|         <li class="tab" id="text-tab"><a href="#text">Text</a></li> | ||||
|         <li class="tab" id="screenshot-tab"><a href="#screenshot">Screenshot</a></li> | ||||
|     <script> | ||||
|         const screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid)}}"; | ||||
|         {% if last_error_screenshot %} | ||||
|             const error_screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||
|         {% endif %} | ||||
|     </ul> | ||||
| </div> | ||||
| <form><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"></form> | ||||
| <div id="diff-ui"> | ||||
|     <div class="tab-pane-inner" id="error-text"> | ||||
|         <div class="snapshot-age error">{{watch.error_text_ctime|format_seconds_ago}} seconds ago</div> | ||||
|         <pre> | ||||
|         const highlight_submit_ignore_url = "{{url_for('highlight_submit_ignore_url', uuid=uuid)}}"; | ||||
|     </script> | ||||
|     <script src="{{ url_for('static_content', group='js', filename='diff-overview.js') }}" defer></script> | ||||
|     <script src="{{ url_for('static_content', group='js', filename='preview.js') }}" defer></script> | ||||
|     <script src="{{ url_for('static_content', group='js', filename='tabs.js') }}" defer></script> | ||||
|     {% if versions|length >= 2 %} | ||||
|         <div id="settings" style="text-align: center;"> | ||||
|             <form class="pure-form " action="" method="POST"> | ||||
|                 <fieldset> | ||||
|                     <label for="preview-version">Select timestamp</label> <select id="preview-version" | ||||
|                                                                                  name="from_version" | ||||
|                                                                                  class="needs-localtime"> | ||||
|                     {% for version in versions|reverse %} | ||||
|                         <option value="{{ version }}" {% if version == current_version %} selected="" {% endif %}> | ||||
|                             {{ version }} | ||||
|                         </option> | ||||
|                     {% endfor %} | ||||
|                 </select> | ||||
|                     <button type="submit" class="pure-button pure-button-primary">Go</button> | ||||
|  | ||||
|                 </fieldset> | ||||
|             </form> | ||||
|                 <br> | ||||
|                 <strong>Keyboard: </strong><a href="" class="pure-button pure-button-primary" id="btn-previous"> | ||||
|                 ← Previous</a>   <a class="pure-button pure-button-primary" id="btn-next" href=""> | ||||
|                 → Next</a> | ||||
|         </div> | ||||
|     {% endif %} | ||||
|  | ||||
|     <div class="tabs"> | ||||
|         <ul> | ||||
|             {% if last_error_text %} | ||||
|                 <li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %} | ||||
|             {% if last_error_screenshot %} | ||||
|                 <li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">Error Screenshot</a> | ||||
|                 </li> {% endif %} | ||||
|             {% if history_n > 0 %} | ||||
|                 <li class="tab" id="text-tab"><a href="#text">Text</a></li> | ||||
|                 <li class="tab" id="screenshot-tab"><a href="#screenshot">Screenshot</a></li> | ||||
|             {% endif %} | ||||
|         </ul> | ||||
|     </div> | ||||
|  | ||||
|  | ||||
|     <div id="diff-ui"> | ||||
|         <div class="tab-pane-inner" id="error-text"> | ||||
|             <div class="snapshot-age error">{{ watch.error_text_ctime|format_seconds_ago }} seconds ago</div> | ||||
|             <pre> | ||||
|             {{ last_error_text }} | ||||
|         </pre> | ||||
|         </div> | ||||
|  | ||||
|         <div class="tab-pane-inner" id="error-screenshot"> | ||||
|             <div class="snapshot-age error">{{ watch.snapshot_error_screenshot_ctime|format_seconds_ago }} seconds ago | ||||
|             </div> | ||||
|             <img id="error-screenshot-img" style="max-width: 80%" | ||||
|                  alt="Current erroring screenshot from most recent request"> | ||||
|         </div> | ||||
|  | ||||
|         <div class="tab-pane-inner" id="text"> | ||||
|             <div class="snapshot-age">{{ current_version|format_timestamp_timeago }}</div> | ||||
|             <span class="ignored">Grey lines are ignored</span> <span class="triggered">Blue lines are triggers</span> | ||||
|             <span class="tip"><strong>Pro-tip</strong>: Highlight text to add to ignore filters</span> | ||||
|  | ||||
|             <table> | ||||
|                 <tbody> | ||||
|                 <tr> | ||||
|                     <td id="diff-col" class="highlightable-filter"> | ||||
|                         {% for row in content %} | ||||
|                             <div class="{{ row.classes }}">{{ row.line }}</div> | ||||
|                         {% endfor %} | ||||
|                     </td> | ||||
|                 </tr> | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         </div> | ||||
|  | ||||
|         <div class="tab-pane-inner" id="screenshot"> | ||||
|             <div class="tip"> | ||||
|                 For now, Differences are performed on text, not graphically, only the latest screenshot is available. | ||||
|             </div> | ||||
|             <br> | ||||
|             {% if is_html_webdriver %} | ||||
|                 {% if screenshot %} | ||||
|                     <div class="snapshot-age">{{ watch.snapshot_screenshot_ctime|format_timestamp_timeago }}</div> | ||||
|                     <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"> | ||||
|                 {% else %} | ||||
|                     No screenshot available just yet! Try rechecking the page. | ||||
|                 {% endif %} | ||||
|             {% else %} | ||||
|                 <strong>Screenshot requires Playwright/WebDriver enabled</strong> | ||||
|             {% endif %} | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="tab-pane-inner" id="error-screenshot"> | ||||
|         <div class="snapshot-age error">{{watch.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div> | ||||
|         <img id="error-screenshot-img"  style="max-width: 80%" alt="Current erroring screenshot from most recent request" > | ||||
|     </div> | ||||
|  | ||||
|     <div class="tab-pane-inner" id="text"> | ||||
|         <div class="snapshot-age">{{watch.snapshot_text_ctime|format_timestamp_timeago}}</div> | ||||
|         <span class="ignored">Grey lines are ignored</span> <span class="triggered">Blue lines are triggers</span> <span class="tip"><strong>Pro-tip</strong>: Highlight text to add to ignore filters</span> | ||||
|  | ||||
|         <table> | ||||
|             <tbody> | ||||
|             <tr> | ||||
|                 <td id="diff-col" class="highlightable-filter"> | ||||
|                     {% for row in content %} | ||||
|                     <div class="{{row.classes}}">{{row.line}}</div> | ||||
|                     {% endfor %} | ||||
|                 </td> | ||||
|             </tr> | ||||
|             </tbody> | ||||
|         </table> | ||||
|     </div> | ||||
|  | ||||
|      <div class="tab-pane-inner" id="screenshot"> | ||||
|          <div class="tip"> | ||||
|              For now, Differences are performed on text, not graphically, only the latest screenshot is available. | ||||
|          </div> | ||||
|          <br> | ||||
|          {% if is_html_webdriver %} | ||||
|            {% if screenshot %} | ||||
|              <div class="snapshot-age">{{watch.snapshot_screenshot_ctime|format_timestamp_timeago}}</div> | ||||
|              <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request" > | ||||
|            {% else %} | ||||
|               No screenshot available just yet! Try rechecking the page. | ||||
|            {% endif %} | ||||
|          {% else %} | ||||
|            <strong>Screenshot requires Playwright/WebDriver enabled</strong> | ||||
|          {% endif %} | ||||
|      </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_common_fields.jinja' import render_common_settings_form %} | ||||
| {% from '_helpers.html' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_common_fields.html' import render_common_settings_form %} | ||||
| <script> | ||||
|     const notification_base_url="{{url_for('ajax_callback_send_notification_test', watch_uuid=uuid)}}"; | ||||
|     const notification_base_url="{{url_for('ajax_callback_send_notification_test', mode="global-settings")}}"; | ||||
| {% if emailprefix %} | ||||
|     const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}'); | ||||
| {% endif %} | ||||
| @@ -31,7 +31,7 @@ | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.requests.form.time_between_check, class="time-check-widget") }} | ||||
|                         <span class="pure-form-message-inline">Default time for all watches, when the watch does not have a specific time setting.</span> | ||||
|                         <span class="pure-form-message-inline">Default recheck time for all watches, current system minimum is <i>{{min_system_recheck_seconds}}</i> seconds (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Misc-system-settings#enviroment-variables">more info</a>).</span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.requests.form.jitter_seconds, class="jitter_seconds") }} | ||||
| @@ -62,6 +62,9 @@ | ||||
|                         <span class="pure-form-message-inline">Allow access to view watch diff page when password is enabled (Good for sharing the diff page) | ||||
|                         </span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.rss_hide_muted_watches) }} | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.application.form.pager_size) }} | ||||
|                         <span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span> | ||||
| @@ -108,8 +111,6 @@ | ||||
|                         <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> | ||||
|                     Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a> | ||||
|                 </div> | ||||
|                 <fieldset class="pure-group" id="webdriver-override-options" data-visible-for="application-fetch_backend=html_webdriver"> | ||||
|                     <div class="pure-form-message-inline"> | ||||
| @@ -121,6 +122,18 @@ | ||||
|                         {{ render_field(form.application.form.webdriver_delay) }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <div class="pure-control-group inline-radio"> | ||||
|                     {{ render_field(form.requests.form.default_ua) }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         Applied to all requests.<br><br> | ||||
|                         Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider <a href="https://changedetection.io/tutorial/what-are-main-types-anti-robot-mechanisms">all of the ways that the browser is detected</a>. | ||||
|                     </span> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                                         <br> | ||||
|                     Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a> | ||||
|  | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="filters"> | ||||
| @@ -168,12 +181,12 @@ nav | ||||
|            </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="api"> | ||||
|  | ||||
|                 <h4>API Access</h4> | ||||
|                 <p>Drive your changedetection.io via API, More about <a href="https://github.com/dgtlmoon/changedetection.io/wiki/API-Reference">API access here</a></p> | ||||
|  | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.api_access_token_enabled) }} | ||||
|                     <div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header</div><br> | ||||
|                     <div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header - required for the Chrome Extension to work</div><br> | ||||
|                     <div class="pure-form-message-inline"><br>API Key <span id="api-key">{{api_key}}</span> | ||||
|                         <span style="display:none;" id="api-key-copy" >copy</span> | ||||
|                     </div> | ||||
| @@ -181,6 +194,20 @@ nav | ||||
|                 <div class="pure-control-group"> | ||||
|                     <a href="{{url_for('settings_reset_api_key')}}" class="pure-button button-small button-cancel">Regenerate API key</a> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     <h4>Chrome Extension</h4> | ||||
|                     <p>Easily add any web-page to your changedetection.io installation from within Chrome.</p> | ||||
|                     <strong>Step 1</strong> Install the extension, <strong>Step 2</strong> Navigate to this page, | ||||
|                     <strong>Step 3</strong> Open the extension from the toolbar and click "<i>Sync API Access</i>" | ||||
|                     <p> | ||||
|                         <a id="chrome-extension-link" | ||||
|                            title="Try our new Chrome Extension!" | ||||
|                            href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop"> | ||||
|                             <img src="{{ url_for('static_content', group='images', filename='Google-Chrome-icon.png') }}" alt="Chrome"> | ||||
|                             Chrome Webstore | ||||
|                         </a> | ||||
|                     </p> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="tab-pane-inner" id="proxies"> | ||||
|                 <div id="recommended-proxy"> | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_simple_field, render_field, render_nolabel_field, sort_by_title %} | ||||
| {% from '_helpers.html' import render_simple_field, render_field, render_nolabel_field, sort_by_title %} | ||||
| <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> | ||||
|  | ||||
| @@ -13,7 +13,7 @@ | ||||
|             <div id="watch-add-wrapper-zone"> | ||||
|  | ||||
|                     {{ render_nolabel_field(form.url, placeholder="https://...", required=true) }} | ||||
|                     {{ render_nolabel_field(form.tags, value=active_tag.title if active_tag else '', placeholder="watch label / tag") }} | ||||
|                     {{ render_nolabel_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder="watch label / tag") }} | ||||
|                     {{ render_nolabel_field(form.watch_submit_button, title="Watch this URL!" ) }} | ||||
|                     {{ render_nolabel_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }} | ||||
|             </div> | ||||
| @@ -37,6 +37,7 @@ | ||||
|         <button class="pure-button button-secondary button-xsmall" name="op" value="assign-tag" id="checkbox-assign-tag">Tag</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" name="op" value="clear-errors">Clear errors</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> | ||||
| @@ -45,7 +46,7 @@ | ||||
|     {% endif %} | ||||
|     {% if search_q %}<div id="search-result-info">Searching "<strong><i>{{search_q}}</i></strong>"</div>{% endif %} | ||||
|     <div> | ||||
|         <a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag }}">All</a> | ||||
|         <a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">All</a> | ||||
|  | ||||
|     <!-- tag list --> | ||||
|     {% for uuid, tag in tags %} | ||||
| @@ -66,18 +67,18 @@ | ||||
|             <tr> | ||||
|                 {% 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> | ||||
|                 <th></th> | ||||
|                 <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_uuid)}}"># <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th class="empty-cell"></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_uuid)}}">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_uuid)}}">Last Checked <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th> | ||||
|                 <th class="empty-cell"></th> | ||||
|             </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|             {% if not watches|length %} | ||||
|             <tr> | ||||
|                 <td colspan="6">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('import_page')}}" >import a list</a>.</td> | ||||
|                 <td colspan="6" style="text-wrap: wrap;">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('import_page')}}" >import a list</a>.</td> | ||||
|             </tr> | ||||
|             {% endif %} | ||||
|             {% for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) %} | ||||
| @@ -94,11 +95,11 @@ | ||||
|                 <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_uuid)}}"><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_uuid)}}"><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_uuid)}}"><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> | ||||
| @@ -168,7 +169,7 @@ | ||||
|                 <td> | ||||
|                     <a {% if watch.uuid in queued_uuids %}disabled="true"{% endif %} href="{{ url_for('form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" | ||||
|                        class="recheck pure-button pure-button-primary">{% if watch.uuid in queued_uuids %}Queued{% else %}Recheck{% endif %}</a> | ||||
|                     <a href="{{ url_for('edit_page', uuid=watch.uuid)}}" class="pure-button pure-button-primary">Edit</a> | ||||
|                     <a href="{{ url_for('edit_page', uuid=watch.uuid)}}#general" class="pure-button pure-button-primary">Edit</a> | ||||
|                     {% if watch.history_n >= 2 %} | ||||
|  | ||||
|                         {%  if is_unviewed %} | ||||
| @@ -203,7 +204,7 @@ | ||||
|                 all {% if active_tag_uuid %} in "{{active_tag.title}}"{%endif%}</a> | ||||
|             </li> | ||||
|             <li> | ||||
|                 <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> | ||||
|                 <a href="{{ url_for('rss', tag=active_tag_uuid, 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> | ||||
|         {{ pagination.links }} | ||||
|   | ||||
| @@ -1,4 +1,7 @@ | ||||
| #!/usr/bin/python3 | ||||
| import resource | ||||
| import time | ||||
| from threading import Thread | ||||
|  | ||||
| import pytest | ||||
| from changedetectionio import changedetection_app | ||||
| @@ -23,6 +26,36 @@ def reportlog(pytestconfig): | ||||
|     yield | ||||
|     logger.remove(handler_id) | ||||
|  | ||||
|  | ||||
| def track_memory(memory_usage, ): | ||||
|     while not memory_usage["stop"]: | ||||
|         max_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss | ||||
|         memory_usage["peak"] = max(memory_usage["peak"], max_rss) | ||||
|         time.sleep(0.01)  # Adjust the sleep time as needed | ||||
|  | ||||
| @pytest.fixture(scope='function') | ||||
| def measure_memory_usage(request): | ||||
|     memory_usage = {"peak": 0, "stop": False} | ||||
|     tracker_thread = Thread(target=track_memory, args=(memory_usage,)) | ||||
|     tracker_thread.start() | ||||
|  | ||||
|     yield | ||||
|  | ||||
|     memory_usage["stop"] = True | ||||
|     tracker_thread.join() | ||||
|  | ||||
|     # Note: ru_maxrss is in kilobytes on Unix-based systems | ||||
|     max_memory_used = memory_usage["peak"] / 1024  # Convert to MB | ||||
|     s = f"Peak memory used by the test {request.node.fspath} - '{request.node.name}': {max_memory_used:.2f} MB" | ||||
|     logger.debug(s) | ||||
|  | ||||
|     with open("test-memory.log", 'a') as f: | ||||
|         f.write(f"{s}\n") | ||||
|  | ||||
|     # Assert that the memory usage is less than 200MB | ||||
|     assert max_memory_used < 150, f"Memory usage exceeded 200MB: {max_memory_used:.2f} MB" | ||||
|  | ||||
|  | ||||
| def cleanup(datastore_path): | ||||
|     import glob | ||||
|     # Unlink test output files | ||||
|   | ||||
| @@ -77,13 +77,13 @@ def do_test(client, live_server, make_test_use_extra_browser=False): | ||||
|  | ||||
|  | ||||
| # Requires playwright to be installed | ||||
| def test_request_via_custom_browser_url(client, live_server): | ||||
| def test_request_via_custom_browser_url(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     # We do this so we can grep the logs of the custom container and see if the request actually went through that container | ||||
|     do_test(client, live_server, make_test_use_extra_browser=True) | ||||
|  | ||||
|  | ||||
| def test_request_not_via_custom_browser_url(client, live_server): | ||||
| def test_request_not_via_custom_browser_url(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     # We do this so we can grep the logs of the custom container and see if the request actually went through that container | ||||
|     do_test(client, live_server, make_test_use_extra_browser=False) | ||||
|   | ||||
| @@ -6,7 +6,7 @@ from ..util import live_server_setup, wait_for_all_checks | ||||
| import logging | ||||
|  | ||||
| # Requires playwright to be installed | ||||
| def test_fetch_webdriver_content(client, live_server): | ||||
| def test_fetch_webdriver_content(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     ##################### | ||||
|   | ||||
| @@ -0,0 +1,56 @@ | ||||
| import os | ||||
| from flask import url_for | ||||
| from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
|  | ||||
|  | ||||
| def test_execute_custom_js(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     live_server_setup(live_server) | ||||
|     assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||
|  | ||||
|     test_url = url_for('test_interactive_html_endpoint', _external=True) | ||||
|     test_url = test_url.replace('localhost.localdomain', 'cdio') | ||||
|     test_url = test_url.replace('localhost', 'cdio') | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first", unpause_on_save=1), | ||||
|         data={ | ||||
|             "url": test_url, | ||||
|             "tags": "", | ||||
|             'fetch_backend': "html_webdriver", | ||||
|             'webdriver_js_execute_code': 'document.querySelector("button[name=test-button]").click();', | ||||
|             'headers': "testheader: yes\buser-agent: MyCustomAgent", | ||||
|         }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"unpaused" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, "Watch history had atleast 1 (everything fetched OK)" | ||||
|  | ||||
|     assert b"This text should be removed" not in res.data | ||||
|  | ||||
|     # Check HTML conversion detected and workd | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid=uuid), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"This text should be removed" not in res.data | ||||
|     assert b"I smell JavaScript because the button was pressed" in res.data | ||||
|  | ||||
|     assert b"testheader: yes" in res.data | ||||
|     assert b"user-agent: mycustomagent" in res.data | ||||
|  | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -5,7 +5,7 @@ from flask import url_for | ||||
| from ..util import live_server_setup, wait_for_all_checks | ||||
|  | ||||
|  | ||||
| def test_preferred_proxy(client, live_server): | ||||
| def test_preferred_proxy(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     url = "http://chosen.changedetection.io" | ||||
|  | ||||
|   | ||||
| @@ -5,7 +5,7 @@ from flask import url_for | ||||
| from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
|  | ||||
|  | ||||
| def test_noproxy_option(client, live_server): | ||||
| def test_noproxy_option(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     # Run by run_proxy_tests.sh | ||||
|     # Call this URL then scan the containers that it never went through them | ||||
|   | ||||
| @@ -5,7 +5,7 @@ from flask import url_for | ||||
| from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
|  | ||||
| # just make a request, we will grep in the docker logs to see it actually got called | ||||
| def test_check_basic_change_detection_functionality(client, live_server): | ||||
| def test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|   | ||||
| @@ -6,7 +6,7 @@ from ..util import live_server_setup, wait_for_all_checks | ||||
| import os | ||||
|  | ||||
| # just make a request, we will grep in the docker logs to see it actually got called | ||||
| def test_select_custom(client, live_server): | ||||
| def test_select_custom(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     # Goto settings, add our custom one | ||||
|   | ||||
| @@ -5,7 +5,7 @@ from flask import url_for | ||||
| from changedetectionio.tests.util import live_server_setup, wait_for_all_checks | ||||
|  | ||||
|  | ||||
| def test_socks5(client, live_server): | ||||
| def test_socks5(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     # Setup a proxy | ||||
|   | ||||
| @@ -7,7 +7,7 @@ from changedetectionio.tests.util import live_server_setup, wait_for_all_checks | ||||
|  | ||||
| # should be proxies.json mounted from run_proxy_tests.sh already | ||||
| # -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json | ||||
| def test_socks5_from_proxiesjson_file(client, live_server): | ||||
| def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     test_url = "https://changedetection.io/CHANGELOG.txt?socks-test-tag=" + os.getenv('SOCKSTEST', '') | ||||
|   | ||||
| @@ -48,7 +48,7 @@ def set_back_in_stock_response(): | ||||
|     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): | ||||
| def test_restock_detection(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     set_original_response() | ||||
|     #assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||
| @@ -95,7 +95,7 @@ def test_restock_detection(client, live_server): | ||||
|  | ||||
|     # We should have a notification | ||||
|     time.sleep(2) | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
|     assert os.path.isfile("test-datastore/notification.txt"), "Notification received" | ||||
|     os.unlink("test-datastore/notification.txt") | ||||
|  | ||||
|     # Default behaviour is to only fire notification when it goes OUT OF STOCK -> IN STOCK | ||||
| @@ -103,4 +103,9 @@ def test_restock_detection(client, live_server): | ||||
|     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") | ||||
|     assert not os.path.isfile("test-datastore/notification.txt"), "No notification should have fired when it went OUT OF STOCK by default" | ||||
|  | ||||
|     # BUT we should see that it correctly shows "not in stock" | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'not-in-stock' in res.data, "Correctly showing NOT IN STOCK in the list after it changed from IN STOCK" | ||||
|  | ||||
|   | ||||
| @@ -1,42 +1,51 @@ | ||||
| #!/usr/bin/python3 | ||||
| import smtpd | ||||
| import asyncore | ||||
| import asyncio | ||||
| from aiosmtpd.controller import Controller | ||||
| from aiosmtpd.smtp import SMTP | ||||
|  | ||||
| # Accept a SMTP message and offer a way to retrieve the last message via TCP Socket | ||||
|  | ||||
| last_received_message = b"Nothing" | ||||
|  | ||||
|  | ||||
| class CustomSMTPServer(smtpd.SMTPServer): | ||||
|  | ||||
|     def process_message(self, peer, mailfrom, rcpttos, data, **kwargs): | ||||
| class CustomSMTPHandler: | ||||
|     async def handle_DATA(self, server, session, envelope): | ||||
|         global last_received_message | ||||
|         last_received_message = data | ||||
|         print('Receiving message from:', peer) | ||||
|         print('Message addressed from:', mailfrom) | ||||
|         print('Message addressed to  :', rcpttos) | ||||
|         print('Message length        :', len(data)) | ||||
|         print(data.decode('utf8')) | ||||
|         return | ||||
|         last_received_message = envelope.content | ||||
|         print('Receiving message from:', session.peer) | ||||
|         print('Message addressed from:', envelope.mail_from) | ||||
|         print('Message addressed to  :', envelope.rcpt_tos) | ||||
|         print('Message length        :', len(envelope.content)) | ||||
|         print(envelope.content.decode('utf8')) | ||||
|         return '250 Message accepted for delivery' | ||||
|  | ||||
|  | ||||
| # Just print out the last message received on plain TCP socket server | ||||
| class EchoServer(asyncore.dispatcher): | ||||
|  | ||||
|     def __init__(self, host, port): | ||||
|         asyncore.dispatcher.__init__(self) | ||||
|         self.create_socket() | ||||
|         self.set_reuse_addr() | ||||
|         self.bind((host, port)) | ||||
|         self.listen(5) | ||||
|  | ||||
|     def handle_accepted(self, sock, addr): | ||||
| class EchoServerProtocol(asyncio.Protocol): | ||||
|     def connection_made(self, transport): | ||||
|         global last_received_message | ||||
|         print('Incoming connection from %s' % repr(addr)) | ||||
|         sock.send(last_received_message) | ||||
|         self.transport = transport | ||||
|         peername = transport.get_extra_info('peername') | ||||
|         print('Incoming connection from {}'.format(peername)) | ||||
|         self.transport.write(last_received_message) | ||||
|  | ||||
|         last_received_message = b'' | ||||
|         self.transport.close() | ||||
|  | ||||
|  | ||||
| server = CustomSMTPServer(('0.0.0.0', 11025), None)  # SMTP mail goes here | ||||
| server2 = EchoServer('0.0.0.0', 11080)  # Echo back last message received | ||||
| asyncore.loop() | ||||
| async def main(): | ||||
|     # Start the SMTP server | ||||
|     controller = Controller(CustomSMTPHandler(), hostname='0.0.0.0', port=11025) | ||||
|     controller.start() | ||||
|  | ||||
|     # Start the TCP Echo server | ||||
|     loop = asyncio.get_running_loop() | ||||
|     server = await loop.create_server( | ||||
|         lambda: EchoServerProtocol(), | ||||
|         '0.0.0.0', 11080 | ||||
|     ) | ||||
|     async with server: | ||||
|         await server.serve_forever() | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(main()) | ||||
|   | ||||
| @@ -32,13 +32,15 @@ def get_last_message_from_smtp_server(): | ||||
|     client_socket.connect((smtp_test_server, port))  # connect to the server | ||||
|  | ||||
|     data = client_socket.recv(50024).decode()  # receive response | ||||
|     logging.info("get_last_message_from_smtp_server..") | ||||
|     logging.info(data) | ||||
|     client_socket.close()  # close the connection | ||||
|     return data | ||||
|  | ||||
|  | ||||
| # Requires running the test SMTP server | ||||
|  | ||||
| def test_check_notification_email_formats_default_HTML(client, live_server): | ||||
| def test_check_notification_email_formats_default_HTML(client, live_server, measure_memory_usage): | ||||
|     # live_server_setup(live_server) | ||||
|     set_original_response() | ||||
|  | ||||
| @@ -71,6 +73,8 @@ def test_check_notification_email_formats_default_HTML(client, live_server): | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     set_longer_modified_response() | ||||
|     time.sleep(2) | ||||
|  | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
| @@ -81,14 +85,14 @@ def test_check_notification_email_formats_default_HTML(client, live_server): | ||||
|  | ||||
|     # The email should have two bodies, and the text/html part should be <br> | ||||
|     assert 'Content-Type: text/plain' in msg | ||||
|     assert '(added) So let\'s see what happens.\n' in msg  # The plaintext part with \n | ||||
|     assert '(added) So let\'s see what happens.\r\n' in msg  # The plaintext part with \r\n | ||||
|     assert 'Content-Type: text/html' in msg | ||||
|     assert '(added) So let\'s see what happens.<br>' in msg  # the html part | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| def test_check_notification_email_formats_default_Text_override_HTML(client, live_server): | ||||
| def test_check_notification_email_formats_default_Text_override_HTML(client, live_server, measure_memory_usage): | ||||
|     # live_server_setup(live_server) | ||||
|  | ||||
|     # HTML problems? see this | ||||
| @@ -135,6 +139,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     set_longer_modified_response() | ||||
|     time.sleep(2) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
| @@ -147,7 +152,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv | ||||
|     # The email should not have two bodies, should be TEXT only | ||||
|  | ||||
|     assert 'Content-Type: text/plain' in msg | ||||
|     assert '(added) So let\'s see what happens.\n' in msg  # The plaintext part with \n | ||||
|     assert '(added) So let\'s see what happens.\r\n' in msg  # The plaintext part with \r\n | ||||
|  | ||||
|     set_original_response() | ||||
|     # Now override as HTML format | ||||
| @@ -168,7 +173,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv | ||||
|  | ||||
|     # The email should have two bodies, and the text/html part should be <br> | ||||
|     assert 'Content-Type: text/plain' in msg | ||||
|     assert '(removed) So let\'s see what happens.\n' in msg  # The plaintext part with \n | ||||
|     assert '(removed) So let\'s see what happens.\r\n' in msg  # The plaintext part with \n | ||||
|     assert 'Content-Type: text/html' in msg | ||||
|     assert '(removed) So let\'s see what happens.<br>' in msg  # the html part | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import os.path | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
| @@ -35,10 +35,10 @@ def set_original(excluding=None, add_line=None): | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
| def test_setup(client, live_server): | ||||
| def test_setup(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def test_check_removed_line_contains_trigger(client, live_server): | ||||
| def test_check_removed_line_contains_trigger(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
| @@ -103,11 +103,10 @@ def test_check_removed_line_contains_trigger(client, live_server): | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| def test_check_add_line_contains_trigger(client, live_server): | ||||
| def test_check_add_line_contains_trigger(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     # 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( | ||||
| @@ -166,6 +165,7 @@ def test_check_add_line_contains_trigger(client, live_server): | ||||
|  | ||||
|     # Takes a moment for apprise to fire | ||||
|     time.sleep(3) | ||||
|     assert os.path.isfile("test-datastore/notification.txt"), "Notification fired because I can see the output file" | ||||
|     with open("test-datastore/notification.txt", 'r') as f: | ||||
|         response= f.read() | ||||
|         assert '-Oh yes please-' in response | ||||
|   | ||||
| @@ -53,10 +53,10 @@ def is_valid_uuid(val): | ||||
|         return False | ||||
|  | ||||
|  | ||||
| def test_setup(client, live_server): | ||||
| def test_setup(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def test_api_simple(client, live_server): | ||||
| def test_api_simple(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
| @@ -149,6 +149,15 @@ def test_api_simple(client, live_server): | ||||
|         headers={'x-api-key': api_key}, | ||||
|     ) | ||||
|     assert b'which has this one new line' in res.data | ||||
|     assert b'<div id' not in res.data | ||||
|  | ||||
|     # Fetch the HTML of the latest one | ||||
|     res = client.get( | ||||
|         url_for("watchsinglehistory", uuid=watch_uuid, timestamp='latest')+"?html=1", | ||||
|         headers={'x-api-key': api_key}, | ||||
|     ) | ||||
|     assert b'which has this one new line' in res.data | ||||
|     assert b'<div id' in res.data | ||||
|  | ||||
|     # Fetch the whole watch | ||||
|     res = client.get( | ||||
| @@ -232,7 +241,7 @@ def test_api_simple(client, live_server): | ||||
|     ) | ||||
|     assert len(res.json) == 0, "Watch list should be empty" | ||||
|  | ||||
| def test_access_denied(client, live_server): | ||||
| def test_access_denied(client, live_server, measure_memory_usage): | ||||
|     # `config_api_token_enabled` Should be On by default | ||||
|     res = client.get( | ||||
|         url_for("createwatch") | ||||
| @@ -278,7 +287,7 @@ def test_access_denied(client, live_server): | ||||
|     ) | ||||
|     assert b"Settings updated." in res.data | ||||
|  | ||||
| def test_api_watch_PUT_update(client, live_server): | ||||
| def test_api_watch_PUT_update(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
| @@ -360,7 +369,7 @@ def test_api_watch_PUT_update(client, live_server): | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| def test_api_import(client, live_server): | ||||
| def test_api_import(client, live_server, measure_memory_usage): | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
|  | ||||
|     res = client.post( | ||||
|   | ||||
| @@ -2,13 +2,12 @@ | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from . util import live_server_setup | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
|  | ||||
| def test_basic_auth(client, live_server): | ||||
|  | ||||
| def test_basic_auth(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     live_server_setup(live_server) | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_basicauth_method', _external=True).replace("//","//myuser:mypass@") | ||||
| @@ -19,8 +18,8 @@ def test_basic_auth(client, live_server): | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Check form validation | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
| @@ -29,7 +28,7 @@ def test_basic_auth(client, live_server): | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     time.sleep(1) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|   | ||||
| @@ -76,11 +76,11 @@ def set_response_without_ldjson(): | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
| def test_setup(client, live_server): | ||||
| def test_setup(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| # actually only really used by the distll.io importer, but could be handy too | ||||
| def test_check_ldjson_price_autodetect(client, live_server): | ||||
| def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     set_response_with_ldjson() | ||||
|  | ||||
| @@ -100,11 +100,12 @@ def test_check_ldjson_price_autodetect(client, live_server): | ||||
|  | ||||
|     # Accept it | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|  | ||||
|     time.sleep(1) | ||||
|     client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True)) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Trigger a check | ||||
|     time.sleep(1) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     # Offer should be gone | ||||
| @@ -166,7 +167,7 @@ def _test_runner_check_bad_format_ignored(live_server, client, has_ldjson_price_ | ||||
|     client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|  | ||||
|  | ||||
| def test_bad_ldjson_is_correctly_ignored(client, live_server): | ||||
| def test_bad_ldjson_is_correctly_ignored(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|     test_return_data = """ | ||||
|             <html> | ||||
|   | ||||
| @@ -3,7 +3,8 @@ | ||||
| import time | ||||
| from flask import url_for | ||||
| from urllib.request import urlopen | ||||
| from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI | ||||
| from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \ | ||||
|     extract_UUID_from_client | ||||
|  | ||||
| sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
| @@ -16,7 +17,7 @@ def test_inscriptus(): | ||||
|     assert stripped_text_from_html == 'test!\nok man' | ||||
|  | ||||
|  | ||||
| def test_check_basic_change_detection_functionality(client, live_server): | ||||
| def test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage): | ||||
|     set_original_response() | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| @@ -62,9 +63,6 @@ def test_check_basic_change_detection_functionality(client, live_server): | ||||
|     # Make a change | ||||
|     set_modified_response() | ||||
|  | ||||
|     res = urlopen(url_for('test_endpoint', _external=True)) | ||||
|     assert b'which has this one new line' in res.read() | ||||
|  | ||||
|     # Force recheck | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
| @@ -135,12 +133,23 @@ def test_check_basic_change_detection_functionality(client, live_server): | ||||
|     # It should have picked up the <title> | ||||
|     assert b'head title' in res.data | ||||
|  | ||||
|     # Be sure the last_viewed is going to be greater than the last snapshot | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # hit the mark all viewed link | ||||
|     res = client.get(url_for("mark_all_viewed"), follow_redirects=True) | ||||
|  | ||||
|     assert b'Mark all viewed' not in res.data | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     # #2458 "clear history" should make the Watch object update its status correctly when the first snapshot lands again | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     client.get(url_for("clear_watch_history", uuid=uuid)) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'preview/' in res.data | ||||
|  | ||||
|     # | ||||
|     # Cleanup everything | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import re | ||||
| import time | ||||
|  | ||||
|  | ||||
| def test_backup(client, live_server): | ||||
| def test_backup(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     set_original_response() | ||||
|   | ||||
| @@ -60,7 +60,7 @@ def set_modified_response_minus_block_text(): | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|  | ||||
| def test_check_block_changedetection_text_NOT_present(client, live_server): | ||||
| def test_check_block_changedetection_text_NOT_present(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     live_server_setup(live_server) | ||||
|     # Use a mix of case in ZzZ to prove it works case-insensitive. | ||||
|   | ||||
| @@ -6,7 +6,7 @@ from . util import live_server_setup | ||||
|  | ||||
|  | ||||
|  | ||||
| def test_trigger_functionality(client, live_server): | ||||
| def test_trigger_functionality(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|   | ||||
| @@ -70,7 +70,7 @@ def test_include_filters_output(): | ||||
|  | ||||
|  | ||||
| # Tests the whole stack works with the CSS Filter | ||||
| def test_check_markup_include_filters_restriction(client, live_server): | ||||
| def test_check_markup_include_filters_restriction(client, live_server, measure_memory_usage): | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|     include_filters = "#sametext" | ||||
| @@ -124,7 +124,7 @@ def test_check_markup_include_filters_restriction(client, live_server): | ||||
|  | ||||
|  | ||||
| # Tests the whole stack works with the CSS Filter | ||||
| def test_check_multiple_filters(client, live_server): | ||||
| def test_check_multiple_filters(client, live_server, measure_memory_usage): | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|     include_filters = "#blob-a\r\nxpath://*[contains(@id,'blob-b')]" | ||||
| @@ -180,7 +180,7 @@ def test_check_multiple_filters(client, live_server): | ||||
| # The filter exists, but did not contain anything useful | ||||
| # Mainly used when the filter contains just an IMG, this can happen when someone selects an image in the visual-selector | ||||
| # Tests fetcher can throw a "ReplyWithContentButNoText" exception after applying filter and extracting text | ||||
| def test_filter_is_empty_help_suggestion(client, live_server): | ||||
| def test_filter_is_empty_help_suggestion(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     include_filters = "#blob-a" | ||||
|   | ||||
| @@ -106,7 +106,7 @@ across multiple lines | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def test_element_removal_full(client, live_server): | ||||
| def test_element_removal_full(client, live_server, measure_memory_usage): | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|     set_original_response() | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import live_server_setup | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
| import pytest | ||||
|  | ||||
|  | ||||
| @@ -24,12 +24,9 @@ def set_html_response(): | ||||
|  | ||||
|  | ||||
| # In the case the server does not issue a charset= or doesnt have content_type header set | ||||
| def test_check_encoding_detection(client, live_server): | ||||
| def test_check_encoding_detection(client, live_server, measure_memory_usage): | ||||
|     set_html_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', content_type="text/html", _external=True) | ||||
|     client.post( | ||||
| @@ -39,7 +36,7 @@ def test_check_encoding_detection(client, live_server): | ||||
|     ) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(2) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
| @@ -53,12 +50,9 @@ def test_check_encoding_detection(client, live_server): | ||||
|  | ||||
|  | ||||
| # In the case the server does not issue a charset= or doesnt have content_type header set | ||||
| def test_check_encoding_detection_missing_content_type_header(client, live_server): | ||||
| def test_check_encoding_detection_missing_content_type_header(client, live_server, measure_memory_usage): | ||||
|     set_html_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     client.post( | ||||
| @@ -67,8 +61,7 @@ def test_check_encoding_detection_missing_content_type_header(client, live_serve | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(2) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|   | ||||
| @@ -54,7 +54,7 @@ def _runner_test_http_errors(client, live_server, http_code, expected_text): | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| def test_http_error_handler(client, live_server): | ||||
| def test_http_error_handler(client, live_server, measure_memory_usage): | ||||
|     _runner_test_http_errors(client, live_server, 403, 'Access denied') | ||||
|     _runner_test_http_errors(client, live_server, 404, 'Page not found') | ||||
|     _runner_test_http_errors(client, live_server, 500, '(Internal server error) received') | ||||
| @@ -63,7 +63,7 @@ def test_http_error_handler(client, live_server): | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| # Just to be sure error text is properly handled | ||||
| def test_DNS_errors(client, live_server): | ||||
| def test_DNS_errors(client, live_server, measure_memory_usage): | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
| @@ -87,7 +87,7 @@ def test_DNS_errors(client, live_server): | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| # Re 1513 | ||||
| def test_low_level_errors_clear_correctly(client, live_server): | ||||
| def test_low_level_errors_clear_correctly(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|   | ||||
| @@ -9,7 +9,7 @@ sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|  | ||||
|  | ||||
| def test_check_extract_text_from_diff(client, live_server): | ||||
| def test_check_extract_text_from_diff(client, live_server, measure_memory_usage): | ||||
|     import time | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write("Now it's {} seconds since epoch, time flies!".format(str(time.time()))) | ||||
| @@ -29,6 +29,7 @@ def test_check_extract_text_from_diff(client, live_server): | ||||
|     # Load in 5 different numbers/changes | ||||
|     last_date="" | ||||
|     for n in range(5): | ||||
|         time.sleep(1) | ||||
|         # Give the thread time to pick it up | ||||
|         print("Bumping snapshot and checking.. ", n) | ||||
|         last_date = str(time.time()) | ||||
|   | ||||
| @@ -67,10 +67,10 @@ def set_multiline_response(): | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def test_setup(client, live_server): | ||||
| def test_setup(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def test_check_filter_multiline(client, live_server): | ||||
| def test_check_filter_multiline(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|     set_multiline_response() | ||||
|  | ||||
| @@ -122,7 +122,7 @@ def test_check_filter_multiline(client, live_server): | ||||
|     # but the last one, which also says 'lines' shouldnt be here (non-greedy match checking) | ||||
|     assert b'aaand something lines' not in res.data | ||||
|  | ||||
| def test_check_filter_and_regex_extract(client, live_server): | ||||
| def test_check_filter_and_regex_extract(client, live_server, measure_memory_usage): | ||||
|      | ||||
|     include_filters = ".changetext" | ||||
|  | ||||
| @@ -205,7 +205,7 @@ def test_check_filter_and_regex_extract(client, live_server): | ||||
|  | ||||
|  | ||||
|  | ||||
| def test_regex_error_handling(client, live_server): | ||||
| def test_regex_error_handling(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|   | ||||
| @@ -41,7 +41,7 @@ def set_response_with_filter(): | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
| def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_server): | ||||
| def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_server, measure_memory_usage): | ||||
| #  Filter knowingly doesn't exist, like someone setting up a known filter to see if some cinema tickets are on sale again | ||||
| #  And the page has that filter available | ||||
| #  Then I should get a notification | ||||
|   | ||||
| @@ -21,10 +21,11 @@ def set_response_with_filter(): | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
| def run_filter_test(client, content_filter): | ||||
| def run_filter_test(client, live_server, content_filter): | ||||
|  | ||||
|     # Response WITHOUT the filter ID element | ||||
|     set_original_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|     # cleanup for the next | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
| @@ -79,6 +80,7 @@ def run_filter_test(client, content_filter): | ||||
|         "include_filters": content_filter, | ||||
|         "fetch_backend": "html_requests"}) | ||||
|  | ||||
|     # A POST here will also reset the filter failure counter (filter_failure_notification_threshold_attempts) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data=notification_form_data, | ||||
| @@ -91,20 +93,21 @@ def run_filter_test(client, content_filter): | ||||
|     # Now the notification should not exist, because we didnt reach the threshold | ||||
|     assert not os.path.isfile("test-datastore/notification.txt") | ||||
|  | ||||
|     # -2 because we would have checked twice above (on adding and on edit) | ||||
|     # recheck it up to just before the threshold, including the fact that in the previous POST it would have rechecked (and incremented) | ||||
|     for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT-2): | ||||
|         res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|         wait_for_all_checks(client) | ||||
|         assert not os.path.isfile("test-datastore/notification.txt"), f"test-datastore/notification.txt should not exist - Attempt {i}" | ||||
|         time.sleep(2) # delay for apprise to fire | ||||
|         assert not os.path.isfile("test-datastore/notification.txt"), f"test-datastore/notification.txt should not exist - Attempt {i} when threshold is {App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT}" | ||||
|  | ||||
|     # We should see something in the frontend | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'Warning, no filters were found' in res.data | ||||
|  | ||||
|     # One more check should trigger it (see -2 above) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     # One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     time.sleep(2)  # delay for apprise to fire | ||||
|     # Now it should exist and contain our "filter not found" alert | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
|  | ||||
| @@ -148,14 +151,10 @@ def run_filter_test(client, content_filter): | ||||
| def test_setup(live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def test_check_include_filters_failure_notification(client, live_server): | ||||
|     set_original_response() | ||||
|     wait_for_all_checks(client) | ||||
|     run_filter_test(client, '#nope-doesnt-exist') | ||||
| def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage): | ||||
|     run_filter_test(client, live_server,'#nope-doesnt-exist') | ||||
|  | ||||
| def test_check_xpath_filter_failure_notification(client, live_server): | ||||
|     set_original_response() | ||||
|     time.sleep(1) | ||||
|     run_filter_test(client, '//*[@id="nope-doesnt-exist"]') | ||||
| def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage): | ||||
|     run_filter_test(client, live_server, '//*[@id="nope-doesnt-exist"]') | ||||
|  | ||||
| # Test that notification is never sent | ||||
|   | ||||
| @@ -6,7 +6,7 @@ from .util import live_server_setup, wait_for_all_checks, extract_rss_token_from | ||||
| import os | ||||
|  | ||||
|  | ||||
| def test_setup(client, live_server): | ||||
| def test_setup(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def set_original_response(): | ||||
| @@ -39,7 +39,7 @@ def set_modified_response(): | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
| def test_setup_group_tag(client, live_server): | ||||
| def test_setup_group_tag(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|     set_original_response() | ||||
|  | ||||
| @@ -100,6 +100,12 @@ def test_setup_group_tag(client, live_server): | ||||
|     assert b'Should be only this' in res.data | ||||
|     assert b'And never this' not in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     # 2307 the UI notice should appear in the placeholder | ||||
|     assert b'WARNING: Watch has tag/groups set with special filters' in res.data | ||||
|  | ||||
|     # RSS Group tag filter | ||||
|     # An extra one that should be excluded | ||||
| @@ -124,7 +130,7 @@ def test_setup_group_tag(client, live_server): | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_tag_import_singular(client, live_server): | ||||
| def test_tag_import_singular(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
| @@ -144,7 +150,7 @@ def test_tag_import_singular(client, live_server): | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_tag_add_in_ui(client, live_server): | ||||
| def test_tag_add_in_ui(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
| # | ||||
|     res = client.post( | ||||
| @@ -161,7 +167,7 @@ def test_tag_add_in_ui(client, live_server): | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_group_tag_notification(client, live_server): | ||||
| def test_group_tag_notification(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|     set_original_response() | ||||
|  | ||||
| @@ -229,7 +235,7 @@ def test_group_tag_notification(client, live_server): | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_limit_tag_ui(client, live_server): | ||||
| def test_limit_tag_ui(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
| @@ -267,7 +273,7 @@ def test_limit_tag_ui(client, live_server): | ||||
|     assert b'Deleted' in res.data | ||||
|     res = client.get(url_for("tags.delete_all"), follow_redirects=True) | ||||
|     assert b'All tags deleted' in res.data | ||||
| def test_clone_tag_on_import(client, live_server): | ||||
| def test_clone_tag_on_import(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
| @@ -292,7 +298,7 @@ def test_clone_tag_on_import(client, live_server): | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_clone_tag_on_quickwatchform_add(client, live_server): | ||||
| def test_clone_tag_on_quickwatchform_add(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
| @@ -321,3 +327,154 @@ def test_clone_tag_on_quickwatchform_add(client, live_server): | ||||
|  | ||||
|     res = client.get(url_for("tags.delete_all"), follow_redirects=True) | ||||
|     assert b'All tags deleted' in res.data | ||||
|  | ||||
| def test_order_of_filters_tag_filter_and_watch_filter(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Add a tag with some config, import a tag and it should roughly work | ||||
|     res = client.post( | ||||
|         url_for("tags.form_tag_add"), | ||||
|         data={"name": "test-tag-keep-order"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Tag added" in res.data | ||||
|     assert b"test-tag-keep-order" in res.data | ||||
|     tag_filters = [ | ||||
|             '#only-this', # duplicated filters | ||||
|             '#only-this', | ||||
|             '#only-this', | ||||
|             '#only-this', | ||||
|             ] | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("tags.form_tag_edit_submit", uuid="first"), | ||||
|         data={"name": "test-tag-keep-order", | ||||
|               "include_filters": '\n'.join(tag_filters) }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated" in res.data | ||||
|     tag_uuid = get_UUID_for_tag_name(client, name="test-tag-keep-order") | ||||
|     res = client.get( | ||||
|         url_for("tags.form_tag_edit", uuid="first") | ||||
|     ) | ||||
|     assert b"#only-this" in res.data | ||||
|  | ||||
|  | ||||
|     d = """<html> | ||||
|        <body> | ||||
|      Some initial text<br> | ||||
|      <p id="only-this">And 1 this</p> | ||||
|      <br> | ||||
|      <p id="not-this">And 2 this</p> | ||||
|      <p id="">And 3 this</p><!--/html/body/p[3]/--> | ||||
|      <p id="">And 4 this</p><!--/html/body/p[4]/--> | ||||
|      <p id="">And 5 this</p><!--/html/body/p[5]/--> | ||||
|      <p id="">And 6 this</p><!--/html/body/p[6]/--> | ||||
|      <p id="">And 7 this</p><!--/html/body/p[7]/--> | ||||
|      <p id="">And 8 this</p><!--/html/body/p[8]/--> | ||||
|      <p id="">And 9 this</p><!--/html/body/p[9]/--> | ||||
|      <p id="">And 10 this</p><!--/html/body/p[10]/--> | ||||
|      <p id="">And 11 this</p><!--/html/body/p[11]/--> | ||||
|      <p id="">And 12 this</p><!--/html/body/p[12]/--> | ||||
|      <p id="">And 13 this</p><!--/html/body/p[13]/--> | ||||
|      <p id="">And 14 this</p><!--/html/body/p[14]/--> | ||||
|      <p id="not-this">And 15 this</p><!--/html/body/p[15]/--> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(d) | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     filters = [ | ||||
|             '/html/body/p[3]', | ||||
|             '/html/body/p[4]', | ||||
|             '/html/body/p[5]', | ||||
|             '/html/body/p[6]', | ||||
|             '/html/body/p[7]', | ||||
|             '/html/body/p[8]', | ||||
|             '/html/body/p[9]', | ||||
|             '/html/body/p[10]', | ||||
|             '/html/body/p[11]', | ||||
|             '/html/body/p[12]', | ||||
|             '/html/body/p[13]', # duplicated tags | ||||
|             '/html/body/p[13]', | ||||
|             '/html/body/p[13]', | ||||
|             '/html/body/p[13]', | ||||
|             '/html/body/p[13]', | ||||
|             '/html/body/p[14]', | ||||
|             ] | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"include_filters": '\n'.join(filters), | ||||
|             "url": test_url, | ||||
|             "tags": "test-tag-keep-order", | ||||
|             "headers": "", | ||||
|             'fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"And 1 this" in res.data  # test-tag-keep-order | ||||
|  | ||||
|     a_tag_filter_check = b'And 1 this' #'#only-this' of tag_filters | ||||
|     # check there is no duplication of tag_filters | ||||
|     assert res.data.count(a_tag_filter_check) == 1, f"duplicated filters didn't removed {res.data.count(a_tag_filter_check)} of {a_tag_filter_check} in {res.data=}" | ||||
|  | ||||
|     a_filter_check = b"And 13 this" # '/html/body/p[13]' | ||||
|     # check there is no duplication of filters | ||||
|     assert res.data.count(a_filter_check) == 1, f"duplicated filters didn't removed. {res.data.count(a_filter_check)} of {a_filter_check} in {res.data=}" | ||||
|  | ||||
|     a_filter_check_not_include = b"And 2 this" # '/html/body/p[2]' | ||||
|     assert a_filter_check_not_include not in res.data | ||||
|  | ||||
|     checklist = [ | ||||
|             b"And 3 this", | ||||
|             b"And 4 this", | ||||
|             b"And 5 this", | ||||
|             b"And 6 this", | ||||
|             b"And 7 this", | ||||
|             b"And 8 this", | ||||
|             b"And 9 this", | ||||
|             b"And 10 this", | ||||
|             b"And 11 this", | ||||
|             b"And 12 this", | ||||
|             b"And 13 this", | ||||
|             b"And 14 this", | ||||
|             b"And 1 this", # result of filter from tag. | ||||
|             ] | ||||
|     # check whether everything a user requested is there | ||||
|     for test in checklist: | ||||
|         assert test in res.data | ||||
|  | ||||
|     # check whether everything a user requested is in order of filters. | ||||
|     n = 0 | ||||
|     for test in checklist: | ||||
|         t_index = res.data[n:].find(test) | ||||
|         # if the text is not searched, return -1. | ||||
|         assert t_index >= 0, f"""failed because {test=} not in {res.data[n:]=} | ||||
| ##################### | ||||
| Looks like some feature changed the order of result of filters. | ||||
| ##################### | ||||
| the {test} appeared before. {test in res.data[:n]=} | ||||
| {res.data[:n]=} | ||||
|         """ | ||||
|         n += t_index + len(test) | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -5,15 +5,13 @@ import os | ||||
| import json | ||||
| import logging | ||||
| from flask import url_for | ||||
| from .util import live_server_setup | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
| from urllib.parse import urlparse, parse_qs | ||||
|  | ||||
| def test_consistent_history(client, live_server): | ||||
| def test_consistent_history(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|     r = range(1, 50) | ||||
|     r = range(1, 30) | ||||
|  | ||||
|     for one in r: | ||||
|         test_url = url_for('test_endpoint', content_type="text/html", content=str(one), _external=True) | ||||
| @@ -25,15 +23,8 @@ def test_consistent_history(client, live_server): | ||||
|  | ||||
|         assert b"1 Imported" in res.data | ||||
|  | ||||
|     time.sleep(3) | ||||
|     while True: | ||||
|         res = client.get(url_for("index")) | ||||
|         logging.debug("Waiting for 'Checking now' to go away..") | ||||
|         if b'Checking now' not in res.data: | ||||
|             break | ||||
|         time.sleep(0.5) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     time.sleep(3) | ||||
|     # Essentially just triggers the DB write/update | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
| @@ -44,8 +35,9 @@ def test_consistent_history(client, live_server): | ||||
|     ) | ||||
|     assert b"Settings updated." in res.data | ||||
|  | ||||
|     # Give it time to write it out | ||||
|     time.sleep(3) | ||||
|  | ||||
|     time.sleep(2) | ||||
|  | ||||
|     json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json') | ||||
|  | ||||
|     json_obj = None | ||||
| @@ -58,7 +50,7 @@ def test_consistent_history(client, live_server): | ||||
|     # each one should have a history.txt containing just one line | ||||
|     for w in json_obj['watching'].keys(): | ||||
|         history_txt_index_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, 'history.txt') | ||||
|         assert os.path.isfile(history_txt_index_file), "History.txt should exist where I expect it - {}".format(history_txt_index_file) | ||||
|         assert os.path.isfile(history_txt_index_file), f"History.txt should exist where I expect it at {history_txt_index_file}" | ||||
|  | ||||
|         # Same like in model.Watch | ||||
|         with open(history_txt_index_file, "r") as f: | ||||
| @@ -70,15 +62,15 @@ def test_consistent_history(client, live_server): | ||||
|                                                      w)) | ||||
|         # Find the snapshot one | ||||
|         for fname in files_in_watch_dir: | ||||
|             if fname != 'history.txt': | ||||
|             if fname != 'history.txt' and 'html' not in fname: | ||||
|                 # contents should match what we requested as content returned from the test url | ||||
|                 with open(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname), 'r') as snapshot_f: | ||||
|                     contents = snapshot_f.read() | ||||
|                     watch_url = json_obj['watching'][w]['url'] | ||||
|                     u = urlparse(watch_url) | ||||
|                     q = parse_qs(u[4]) | ||||
|                     assert q['content'][0] == contents.strip(), "Snapshot file {} should contain {}".format(fname, q['content'][0]) | ||||
|                     assert q['content'][0] == contents.strip(), f"Snapshot file {fname} should contain {q['content'][0]}" | ||||
|  | ||||
|  | ||||
|  | ||||
|         assert len(files_in_watch_dir) == 2, "Should be just two files in the dir, history.txt and the snapshot" | ||||
|         assert len(files_in_watch_dir) == 3, "Should be just three files in the dir, html.br snapshot, history.txt and the extracted text snapshot" | ||||
|   | ||||
| @@ -82,7 +82,7 @@ def set_modified_ignore_response(): | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|  | ||||
| def test_check_ignore_text_functionality(client, live_server): | ||||
| def test_check_ignore_text_functionality(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Use a mix of case in ZzZ to prove it works case-insensitive. | ||||
|     ignore_text = "XXXXX\r\nYYYYY\r\nzZzZZ\r\nnew ignore stuff" | ||||
| @@ -164,7 +164,7 @@ def test_check_ignore_text_functionality(client, live_server): | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_check_global_ignore_text_functionality(client, live_server): | ||||
| def test_check_global_ignore_text_functionality(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|   | ||||
| @@ -23,7 +23,7 @@ def set_original_ignore_response(): | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|  | ||||
| def test_highlight_ignore(client, live_server): | ||||
| def test_highlight_ignore(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     set_original_ignore_response() | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
| @@ -45,7 +45,6 @@ def test_highlight_ignore(client, live_server): | ||||
|     ) | ||||
|  | ||||
|     res = client.get(url_for("edit_page", uuid=uuid)) | ||||
|  | ||||
|     # should be a regex now | ||||
|     assert b'/oh\ yeah\ \d+/' in res.data | ||||
|  | ||||
| @@ -55,3 +54,7 @@ def test_highlight_ignore(client, live_server): | ||||
|     # And it should register in the preview page | ||||
|     res = client.get(url_for("preview_page", uuid=uuid)) | ||||
|     assert b'<div class="ignored">oh yeah 456' in res.data | ||||
|  | ||||
|     # Should be in base.html | ||||
|     assert b'csrftoken' in res.data | ||||
|  | ||||
|   | ||||
| @@ -40,7 +40,7 @@ def set_modified_ignore_response(): | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
| def test_render_anchor_tag_content_true(client, live_server): | ||||
| def test_render_anchor_tag_content_true(client, live_server, measure_memory_usage): | ||||
|     """Testing that the link changes are detected when | ||||
|     render_anchor_tag_content setting is set to true""" | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|   | ||||
| @@ -39,7 +39,7 @@ def set_some_changed_response(): | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|  | ||||
| def test_normal_page_check_works_with_ignore_status_code(client, live_server): | ||||
| def test_normal_page_check_works_with_ignore_status_code(client, live_server, measure_memory_usage): | ||||
|  | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
| @@ -85,7 +85,7 @@ def test_normal_page_check_works_with_ignore_status_code(client, live_server): | ||||
|  | ||||
|  | ||||
| # Tests the whole stack works with staus codes ignored | ||||
| def test_403_page_check_works_with_ignore_status_code(client, live_server): | ||||
| def test_403_page_check_works_with_ignore_status_code(client, live_server, measure_memory_usage): | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|     set_original_response() | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user