mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-11-04 08:34:57 +00:00 
			
		
		
		
	Compare commits
	
		
			49 Commits
		
	
	
		
			0.45.22
			...
			multiple-t
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					62e99d41be | ||
| 
						 | 
					cdf07983db | ||
| 
						 | 
					27401b3202 | ||
| 
						 | 
					4baa9489c3 | ||
| 
						 | 
					503e67f33e | ||
| 
						 | 
					f290d0e5fe | ||
| 
						 | 
					933d4ce886 | ||
| 
						 | 
					50c7e1bf8c | ||
| 
						 | 
					73eb00a3e9 | ||
| 
						 | 
					e75561b2f5 | ||
| 
						 | 
					005ed20741 | ||
| 
						 | 
					2b0f851cbc | ||
| 
						 | 
					56aeefc53e | ||
| 
						 | 
					f7c7a3dbb8 | ||
| 
						 | 
					e5775dd68e | ||
| 
						 | 
					97e4ae1194 | ||
| 
						 | 
					de955a54bd | ||
| 
						 | 
					5037e66ec1 | ||
| 
						 | 
					db48cc64f5 | ||
| 
						 | 
					bce02f9c82 | ||
| 
						 | 
					76ffc3e891 | ||
| 
						 | 
					c6ee6687b5 | ||
| 
						 | 
					de48892243 | ||
| 
						 | 
					6aded50aca | ||
| 
						 | 
					b8e279a025 | ||
| 
						 | 
					8041d00e75 | ||
| 
						 | 
					6a0e14cfce | ||
| 
						 | 
					be91c5425c | ||
| 
						 | 
					778680d517 | ||
| 
						 | 
					7e8aa7e3ff | ||
| 
						 | 
					d77f913aa0 | ||
| 
						 | 
					59cefe58e7 | ||
| 
						 | 
					cfc689e046 | ||
| 
						 | 
					7b04b52e45 | ||
| 
						 | 
					f49eb4567f | ||
| 
						 | 
					a8959be348 | ||
| 
						 | 
					05bf3c9a5c | ||
| 
						 | 
					4293639f51 | ||
| 
						 | 
					f0ed4f64e8 | ||
| 
						 | 
					add2c658b4 | ||
| 
						 | 
					e27f66eb73 | ||
| 
						 | 
					e4504fee49 | ||
| 
						 | 
					5798581f18 | ||
| 
						 | 
					ef910b86ef | ||
| 
						 | 
					8d1fb96d18 | ||
| 
						 | 
					5df5d0fbe7 | ||
| 
						 | 
					815cba11ca | ||
| 
						 | 
					3aed4e5af9 | ||
| 
						 | 
					3618c389c6 | 
							
								
								
									
										221
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										221
									
								
								.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,202 +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
 | 
			
		||||
 | 
			
		||||
      - 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' 
 | 
			
		||||
 | 
			
		||||
      - 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'
 | 
			
		||||
  test-application-3-10:
 | 
			
		||||
    needs: lint-code
 | 
			
		||||
    uses: ./.github/workflows/test-stack-reusable-workflow.yml
 | 
			
		||||
    with:
 | 
			
		||||
      python-version: '3.10'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
      - 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'
 | 
			
		||||
  test-application-3-11:
 | 
			
		||||
    needs: lint-code
 | 
			
		||||
    uses: ./.github/workflows/test-stack-reusable-workflow.yml
 | 
			
		||||
    with:
 | 
			
		||||
      python-version: '3.11'
 | 
			
		||||
      skip-pypuppeteer: true
 | 
			
		||||
 | 
			
		||||
      - 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'
 | 
			
		||||
  test-application-3-12:
 | 
			
		||||
    needs: lint-code
 | 
			
		||||
    uses: ./.github/workflows/test-stack-reusable-workflow.yml
 | 
			
		||||
    with:
 | 
			
		||||
      python-version: '3.12'
 | 
			
		||||
      skip-pypuppeteer: true
 | 
			
		||||
 | 
			
		||||
# STRAIGHT TO CDP
 | 
			
		||||
      - name: Pyppeteer and SocketPuppetBrowser - Specific tests in built container
 | 
			
		||||
        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
 | 
			
		||||
        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,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.10
 | 
			
		||||
 | 
			
		||||
FROM python:${PYTHON_VERSION}-slim-bookworm as builder
 | 
			
		||||
 | 
			
		||||
# See `cryptography` pin comment in requirements.txt
 | 
			
		||||
ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1
 | 
			
		||||
@@ -32,7 +35,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 \
 | 
			
		||||
 
 | 
			
		||||
@@ -257,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,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
 | 
			
		||||
 | 
			
		||||
__version__ = '0.45.22'
 | 
			
		||||
__version__ = '0.45.23'
 | 
			
		||||
 | 
			
		||||
from changedetectionio.strtobool import strtobool
 | 
			
		||||
from json.decoder import JSONDecodeError
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -49,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):
 | 
			
		||||
@@ -87,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
 | 
			
		||||
@@ -194,10 +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, headers=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)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -28,7 +28,7 @@ def manage_user_agent(headers, current_ua=''):
 | 
			
		||||
    :return:
 | 
			
		||||
    """
 | 
			
		||||
    # Ask it what the user agent is, if its obviously ChromeHeadless, switch it to the default
 | 
			
		||||
    ua_in_custom_headers = next((v for k, v in headers.items() if k.lower() == "user-agent"), None)
 | 
			
		||||
    ua_in_custom_headers = headers.get('User-Agent')
 | 
			
		||||
    if ua_in_custom_headers:
 | 
			
		||||
        return ua_in_custom_headers
 | 
			
		||||
 | 
			
		||||
@@ -112,23 +112,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 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()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -119,7 +119,7 @@ class fetcher(Fetcher):
 | 
			
		||||
 | 
			
		||||
            # 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)
 | 
			
		||||
@@ -172,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)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@ from loguru import logger
 | 
			
		||||
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(
 | 
			
		||||
        os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize()
 | 
			
		||||
@@ -93,15 +92,38 @@ 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)}")
 | 
			
		||||
        else:
 | 
			
		||||
            self.page = await browser.newPage()
 | 
			
		||||
 | 
			
		||||
        await self.page.setUserAgent(manage_user_agent(headers=request_headers, current_ua=await self.page.evaluate('navigator.userAgent')))
 | 
			
		||||
        # 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:
 | 
			
		||||
            # 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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -124,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')
 | 
			
		||||
@@ -338,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)
 | 
			
		||||
 | 
			
		||||
@@ -450,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)
 | 
			
		||||
 | 
			
		||||
@@ -467,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,
 | 
			
		||||
@@ -617,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")
 | 
			
		||||
@@ -642,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
 | 
			
		||||
@@ -683,18 +683,8 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
            if request.args.get('unpause_on_save'):
 | 
			
		||||
                extra_update_obj['paused'] = False
 | 
			
		||||
            # Re #110, if they submit the same as the default value, set it to None, so we continue to follow the default
 | 
			
		||||
            # Assume we use the default value, unless something relevant is different, then use the form value
 | 
			
		||||
            # values could be None, 0 etc.
 | 
			
		||||
            # 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
 | 
			
		||||
@@ -775,14 +765,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
 | 
			
		||||
@@ -861,11 +850,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
 | 
			
		||||
 | 
			
		||||
@@ -1075,6 +1066,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':
 | 
			
		||||
@@ -1094,57 +1087,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'],
 | 
			
		||||
@@ -1154,7 +1143,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
 | 
			
		||||
 | 
			
		||||
@@ -1666,14 +1658,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
 | 
			
		||||
@@ -1727,9 +1719,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)
 | 
			
		||||
 
 | 
			
		||||
@@ -453,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='')
 | 
			
		||||
 | 
			
		||||
@@ -525,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):
 | 
			
		||||
@@ -536,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'):
 | 
			
		||||
@@ -565,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,
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ from changedetectionio.notification import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
_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 +23,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 +46,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
 | 
			
		||||
 
 | 
			
		||||
@@ -12,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 (
 | 
			
		||||
@@ -69,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': '',
 | 
			
		||||
@@ -332,7 +333,9 @@ class model(dict):
 | 
			
		||||
        # 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)
 | 
			
		||||
            logger.warning(f"Timestamp {timestamp} already exists, waiting 1 seconds so we have a unique key in history.txt")
 | 
			
		||||
            timestamp = str(int(timestamp) + 1)
 | 
			
		||||
            time.sleep(1)
 | 
			
		||||
 | 
			
		||||
        threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
 | 
			
		||||
        skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
 | 
			
		||||
 
 | 
			
		||||
@@ -48,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')
 | 
			
		||||
 | 
			
		||||
@@ -122,10 +122,6 @@ def process_notification(n_object, datastore):
 | 
			
		||||
    # Insert variables into the notification content
 | 
			
		||||
    notification_parameters = create_notification_parameters(n_object, datastore)
 | 
			
		||||
 | 
			
		||||
    # 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)
 | 
			
		||||
 | 
			
		||||
    n_format = valid_notification_formats.get(
 | 
			
		||||
        n_object.get('notification_format', default_notification_format),
 | 
			
		||||
        valid_notification_formats[default_notification_format],
 | 
			
		||||
@@ -151,6 +147,11 @@ 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.")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,10 @@
 | 
			
		||||
from abc import abstractmethod
 | 
			
		||||
import os
 | 
			
		||||
import hashlib
 | 
			
		||||
import re
 | 
			
		||||
from copy import deepcopy
 | 
			
		||||
from changedetectionio.strtobool import strtobool
 | 
			
		||||
from copy import deepcopy
 | 
			
		||||
from loguru import logger
 | 
			
		||||
import hashlib
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
class difference_detection_processor():
 | 
			
		||||
 | 
			
		||||
@@ -21,7 +21,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 +93,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')))
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 22 KiB  | 
@@ -26,7 +26,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();
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,13 @@ $(document).ready(function () {
 | 
			
		||||
        }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    $('.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) {
 | 
			
		||||
        toggle(location.hash);
 | 
			
		||||
 
 | 
			
		||||
@@ -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'),
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -243,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,
 | 
			
		||||
@@ -928,23 +927,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;
 | 
			
		||||
@@ -1019,6 +1021,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 {
 | 
			
		||||
@@ -1080,6 +1087,9 @@ ul {
 | 
			
		||||
    li {
 | 
			
		||||
      list-style: none;
 | 
			
		||||
      font-size: 0.8rem;
 | 
			
		||||
      > * {
 | 
			
		||||
        display: inline-block;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -574,8 +574,7 @@ body::after {
 | 
			
		||||
  opacity: 0.91; }
 | 
			
		||||
 | 
			
		||||
body::before {
 | 
			
		||||
  content: "";
 | 
			
		||||
  background-size: cover; }
 | 
			
		||||
  content: ""; }
 | 
			
		||||
 | 
			
		||||
body:after,
 | 
			
		||||
body:before {
 | 
			
		||||
@@ -1041,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;
 | 
			
		||||
@@ -1127,6 +1127,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; }
 | 
			
		||||
@@ -1172,6 +1176,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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 | 
			
		||||
@@ -178,7 +178,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
 | 
			
		||||
 | 
			
		||||
@@ -554,7 +554,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', {}))
 | 
			
		||||
@@ -872,3 +871,16 @@ 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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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,12 +26,6 @@
 | 
			
		||||
    <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 src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
 | 
			
		||||
  </head>
 | 
			
		||||
 | 
			
		||||
@@ -89,8 +85,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>
 | 
			
		||||
 
 | 
			
		||||
@@ -87,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) }}
 | 
			
		||||
@@ -330,7 +324,7 @@ nav
 | 
			
		||||
                        </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>
 | 
			
		||||
@@ -439,7 +433,8 @@ Unavailable") }}
 | 
			
		||||
                    <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, this tool is a helper to manage filters in the  "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab.
 | 
			
		||||
                                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>
 | 
			
		||||
                                This tool is a helper to manage filters in the  "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab.
 | 
			
		||||
                            </span>
 | 
			
		||||
 | 
			
		||||
                            <div id="selector-header">
 | 
			
		||||
@@ -487,13 +482,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,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 %}
 | 
			
		||||
 
 | 
			
		||||
@@ -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">
 | 
			
		||||
@@ -190,7 +203,7 @@ nav
 | 
			
		||||
                        <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') }}">
 | 
			
		||||
                            <img src="{{ url_for('static_content', group='images', filename='Google-Chrome-icon.png') }}" alt="Chrome">
 | 
			
		||||
                            Chrome Webstore
 | 
			
		||||
                        </a>
 | 
			
		||||
                    </p>
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -46,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 %}
 | 
			
		||||
@@ -67,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><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></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><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></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) %}
 | 
			
		||||
@@ -95,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>
 | 
			
		||||
@@ -204,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,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,6 +32,8 @@ 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
 | 
			
		||||
 | 
			
		||||
@@ -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,7 +85,7 @@ 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)
 | 
			
		||||
@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -105,6 +105,7 @@ def test_check_ldjson_price_autodetect(client, live_server):
 | 
			
		||||
    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
 | 
			
		||||
 
 | 
			
		||||
@@ -135,6 +135,9 @@ 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)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -479,8 +479,9 @@ def test_correct_header_detect(client, live_server):
 | 
			
		||||
        url_for("preview_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b'"world":' in res.data
 | 
			
		||||
    assert res.data.count(b'{') >= 2
 | 
			
		||||
 | 
			
		||||
    assert b'"hello": 123,' in res.data
 | 
			
		||||
    assert b'"world": 123</div>' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -9,9 +9,6 @@ def test_check_notification_error_handling(client, live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
    set_original_response()
 | 
			
		||||
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
    # Set a URL and fetch it, then set a notification URL which is going to give errors
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
 
 | 
			
		||||
@@ -253,15 +253,99 @@ def test_method_in_request(client, live_server):
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 | 
			
		||||
# Re #2408 - user-agent override test, also should handle case-insensitive header deduplication
 | 
			
		||||
def test_ua_global_override(client, live_server):
 | 
			
		||||
    # live_server_setup(live_server)
 | 
			
		||||
    test_url = url_for('test_headers', _external=True)
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("settings_page"),
 | 
			
		||||
        data={
 | 
			
		||||
            "application-fetch_backend": "html_requests",
 | 
			
		||||
            "application-minutes_between_check": 180,
 | 
			
		||||
            "requests-default_ua-html_requests": "html-requests-user-agent"
 | 
			
		||||
        },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b'Settings updated' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
        data={"urls": test_url},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("preview_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"html-requests-user-agent" in res.data
 | 
			
		||||
    # default user-agent should have shown by now
 | 
			
		||||
    # now add a custom one in the headers
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Add some headers to a request
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        data={
 | 
			
		||||
            "url": test_url,
 | 
			
		||||
            "tags": "testtag",
 | 
			
		||||
            "fetch_backend": 'html_requests',
 | 
			
		||||
            # Important - also test case-insensitive
 | 
			
		||||
            "headers": "User-AGent: agent-from-watch"},
 | 
			
		||||
        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"agent-from-watch" in res.data
 | 
			
		||||
    assert b"html-requests-user-agent" not in res.data
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 | 
			
		||||
def test_headers_textfile_in_request(client, live_server):
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
 | 
			
		||||
    webdriver_ua = "Hello fancy webdriver UA 1.0"
 | 
			
		||||
    requests_ua = "Hello basic requests UA 1.1"
 | 
			
		||||
 | 
			
		||||
    test_url = url_for('test_headers', _external=True)
 | 
			
		||||
    if os.getenv('PLAYWRIGHT_DRIVER_URL'):
 | 
			
		||||
        # Because its no longer calling back to localhost but from the browser container, set in test-only.yml
 | 
			
		||||
        test_url = test_url.replace('localhost', 'cdio')
 | 
			
		||||
 | 
			
		||||
    print ("TEST URL IS ",test_url)
 | 
			
		||||
    form_data = {
 | 
			
		||||
        "application-fetch_backend": "html_requests",
 | 
			
		||||
        "application-minutes_between_check": 180,
 | 
			
		||||
        "requests-default_ua-html_requests": requests_ua
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if os.getenv('PLAYWRIGHT_DRIVER_URL'):
 | 
			
		||||
        form_data["requests-default_ua-html_webdriver"] = webdriver_ua
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("settings_page"),
 | 
			
		||||
        data=form_data,
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b'Settings updated' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("settings_page"))
 | 
			
		||||
 | 
			
		||||
    # Only when some kind of real browser is setup
 | 
			
		||||
    if os.getenv('PLAYWRIGHT_DRIVER_URL'):
 | 
			
		||||
        assert b'requests-default_ua-html_webdriver' in res.data
 | 
			
		||||
 | 
			
		||||
    # Field should always be there
 | 
			
		||||
    assert b"requests-default_ua-html_requests" in res.data
 | 
			
		||||
 | 
			
		||||
    # Add the test URL twice, we will check
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
@@ -272,15 +356,14 @@ def test_headers_textfile_in_request(client, live_server):
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Add some headers to a request
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        data={
 | 
			
		||||
              "url": test_url,
 | 
			
		||||
              "tags": "testtag",
 | 
			
		||||
              "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',
 | 
			
		||||
              "headers": "xxx:ooo\ncool:yeah\r\n"},
 | 
			
		||||
            "url": test_url,
 | 
			
		||||
            "tags": "testtag",
 | 
			
		||||
            "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',
 | 
			
		||||
            "headers": "xxx:ooo\ncool:yeah\r\n"},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
@@ -292,7 +375,7 @@ def test_headers_textfile_in_request(client, live_server):
 | 
			
		||||
    with open('test-datastore/headers.txt', 'w') as f:
 | 
			
		||||
        f.write("global-header: nice\r\nnext-global-header: nice")
 | 
			
		||||
 | 
			
		||||
    with open('test-datastore/'+extract_UUID_from_client(client)+'/headers.txt', 'w') as f:
 | 
			
		||||
    with open('test-datastore/' + extract_UUID_from_client(client) + '/headers.txt', 'w') as f:
 | 
			
		||||
        f.write("watch-header: nice")
 | 
			
		||||
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
@@ -306,7 +389,7 @@ def test_headers_textfile_in_request(client, live_server):
 | 
			
		||||
    # Not needed anymore
 | 
			
		||||
    os.unlink('test-datastore/headers.txt')
 | 
			
		||||
    os.unlink('test-datastore/headers-testtag.txt')
 | 
			
		||||
    os.unlink('test-datastore/'+extract_UUID_from_client(client)+'/headers.txt')
 | 
			
		||||
 | 
			
		||||
    # The service should echo back the request verb
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("preview_page", uuid="first"),
 | 
			
		||||
@@ -319,7 +402,12 @@ def test_headers_textfile_in_request(client, live_server):
 | 
			
		||||
    assert b"Watch-Header:nice" in res.data
 | 
			
		||||
    assert b"Tag-Header:test" in res.data
 | 
			
		||||
 | 
			
		||||
    # Check the custom UA from system settings page made it through
 | 
			
		||||
    if os.getenv('PLAYWRIGHT_DRIVER_URL'):
 | 
			
		||||
        assert "User-Agent:".encode('utf-8') + webdriver_ua.encode('utf-8') in res.data
 | 
			
		||||
    else:
 | 
			
		||||
        assert "User-Agent:".encode('utf-8') + requests_ua.encode('utf-8') in res.data
 | 
			
		||||
 | 
			
		||||
    #unlink headers.txt on start/stop
 | 
			
		||||
    # unlink headers.txt on start/stop
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -54,102 +54,3 @@ def test_check_watch_field_storage(client, live_server):
 | 
			
		||||
    assert b"woohoo" in res.data
 | 
			
		||||
    assert b"curl: foo" in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Re https://github.com/dgtlmoon/changedetection.io/issues/110
 | 
			
		||||
def test_check_recheck_global_setting(client, live_server):
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("settings_page"),
 | 
			
		||||
        data={
 | 
			
		||||
               "requests-time_between_check-minutes": 1566,
 | 
			
		||||
               'application-fetch_backend': "html_requests"
 | 
			
		||||
               },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Settings updated." in res.data
 | 
			
		||||
 | 
			
		||||
    # Now add a record
 | 
			
		||||
 | 
			
		||||
    test_url = "http://somerandomsitewewatch.com"
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
        data={"urls": test_url},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
 | 
			
		||||
    # Now visit the edit page, it should have the default minutes
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Should show the default minutes
 | 
			
		||||
    assert b"change to another value if you want to be specific" in res.data
 | 
			
		||||
    assert b"1566" in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("settings_page"),
 | 
			
		||||
        data={
 | 
			
		||||
               "requests-time_between_check-minutes": 222,
 | 
			
		||||
                'application-fetch_backend': "html_requests"
 | 
			
		||||
               },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Settings updated." in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Should show the default minutes
 | 
			
		||||
    assert b"change to another value if you want to be specific" in res.data
 | 
			
		||||
    assert b"222" in res.data
 | 
			
		||||
 | 
			
		||||
    # Now change it specifically, it should show the new minutes
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        data={"url": test_url,
 | 
			
		||||
              "time_between_check-minutes": 55,
 | 
			
		||||
              'fetch_backend': "html_requests"
 | 
			
		||||
              },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"55" in res.data
 | 
			
		||||
 | 
			
		||||
    # Now submit an empty field, it should give back the default global minutes
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("settings_page"),
 | 
			
		||||
        data={
 | 
			
		||||
               "requests-time_between_check-minutes": 666,
 | 
			
		||||
                "application-fetch_backend": "html_requests"
 | 
			
		||||
               },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Settings updated." in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        data={"url": test_url,
 | 
			
		||||
              "time_between_check-minutes": "",
 | 
			
		||||
              'fetch_backend': "html_requests"
 | 
			
		||||
              },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"666" in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -102,10 +102,9 @@ def test_basic_browserstep(client, live_server):
 | 
			
		||||
            "url": test_url,
 | 
			
		||||
            "tags": "",
 | 
			
		||||
            'fetch_backend': "html_webdriver",
 | 
			
		||||
            'browser_steps-0-operation': 'Goto site',
 | 
			
		||||
            'browser_steps-1-operation': 'Click element',
 | 
			
		||||
            'browser_steps-1-selector': 'button[name=test-button]',
 | 
			
		||||
            'browser_steps-1-optional_value': '',
 | 
			
		||||
            'browser_steps-0-operation': 'Click element',
 | 
			
		||||
            'browser_steps-0-selector': 'button[name=test-button]',
 | 
			
		||||
            'browser_steps-0-optional_value': '',
 | 
			
		||||
            # For now, cookies doesnt work in headers because it must be a full cookiejar object
 | 
			
		||||
            'headers': "testheader: yes\buser-agent: MyCustomAgent",
 | 
			
		||||
        },
 | 
			
		||||
@@ -141,10 +140,9 @@ def test_basic_browserstep(client, live_server):
 | 
			
		||||
              "url": four_o_four_url,
 | 
			
		||||
              "tags": "",
 | 
			
		||||
              'fetch_backend': "html_webdriver",
 | 
			
		||||
              'browser_steps-0-operation': 'Goto site',
 | 
			
		||||
              'browser_steps-1-operation': 'Click element',
 | 
			
		||||
              'browser_steps-1-selector': 'button[name=test-button]',
 | 
			
		||||
              'browser_steps-1-optional_value': ''
 | 
			
		||||
              'browser_steps-0-operation': 'Click element',
 | 
			
		||||
              'browser_steps-0-selector': 'button[name=test-button]',
 | 
			
		||||
              'browser_steps-0-optional_value': ''
 | 
			
		||||
        },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 
 | 
			
		||||
@@ -54,7 +54,9 @@ services:
 | 
			
		||||
  #        
 | 
			
		||||
  #        Default number of parallel/concurrent fetchers
 | 
			
		||||
  #      - FETCH_WORKERS=10
 | 
			
		||||
 | 
			
		||||
  #
 | 
			
		||||
  #        Absolute minimum seconds to recheck, overrides any watch minimum, change to 0 to disable
 | 
			
		||||
  #      - MINIMUM_SECONDS_RECHECK_TIME=3
 | 
			
		||||
      # Comment out ports: when using behind a reverse proxy , enable networks: etc.
 | 
			
		||||
      ports:
 | 
			
		||||
        - 5000:5000
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# Used by Pyppeteer
 | 
			
		||||
pyee
 | 
			
		||||
 | 
			
		||||
eventlet==0.33.3 # related to dnspython fixes
 | 
			
		||||
eventlet==0.35.2 # related to dnspython fixes
 | 
			
		||||
feedgen~=0.9
 | 
			
		||||
flask-compress
 | 
			
		||||
# 0.6.3 included compatibility fix for werkzeug 3.x (2.x had deprecation of url handlers)
 | 
			
		||||
@@ -29,19 +29,17 @@ chardet>2.3.0
 | 
			
		||||
wtforms~=3.0
 | 
			
		||||
jsonpath-ng~=1.5.3
 | 
			
		||||
 | 
			
		||||
# Pinned: module 'eventlet.green.select' has no attribute 'epoll'
 | 
			
		||||
# https://github.com/eventlet/eventlet/issues/805#issuecomment-1640463482
 | 
			
		||||
dnspython==2.3.0 # related to eventlet fixes
 | 
			
		||||
dnspython==2.6.1
 | 
			
		||||
 | 
			
		||||
# jq not available on Windows so must be installed manually
 | 
			
		||||
 | 
			
		||||
# Notification library
 | 
			
		||||
apprise~=1.7.4
 | 
			
		||||
apprise~=1.8.0
 | 
			
		||||
 | 
			
		||||
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
 | 
			
		||||
# and 2.0.0 https://github.com/dgtlmoon/changedetection.io/issues/2241 not yet compatible
 | 
			
		||||
# use v1.x due to https://github.com/eclipse/paho.mqtt.python/issues/814
 | 
			
		||||
paho-mqtt < 2.0.0
 | 
			
		||||
paho-mqtt>=1.6.1,<2.0.0
 | 
			
		||||
 | 
			
		||||
# This mainly affects some ARM builds, which unlike the other builds ignores "ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1"
 | 
			
		||||
# so without this pinning, the newer versions on ARM will forcefully try to build rust, which results in "rust compiler not found"
 | 
			
		||||
@@ -52,7 +50,10 @@ cryptography~=3.4
 | 
			
		||||
beautifulsoup4
 | 
			
		||||
 | 
			
		||||
# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe.
 | 
			
		||||
lxml >=4.8.0,<6
 | 
			
		||||
# #2328 - 5.2.0 and 5.2.1 had extra CPU flag CFLAGS set which was not compatible on older hardware
 | 
			
		||||
#         It could be advantageous to run its own pypi package here with those performance flags set
 | 
			
		||||
#         https://bugs.launchpad.net/lxml/+bug/2059910/comments/16
 | 
			
		||||
lxml >=4.8.0,<6,!=5.2.0,!=5.2.1
 | 
			
		||||
 | 
			
		||||
# XPath 2.0-3.1 support - 4.2.0 broke something?
 | 
			
		||||
elementpath==4.1.5
 | 
			
		||||
@@ -70,12 +71,10 @@ openpyxl
 | 
			
		||||
jq~=1.3; python_version >= "3.8" and sys_platform == "darwin"
 | 
			
		||||
jq~=1.3; python_version >= "3.8" and sys_platform == "linux"
 | 
			
		||||
 | 
			
		||||
# Any current modern version, required so far for screenshot PNG->JPEG conversion but will be used more in the future
 | 
			
		||||
pillow
 | 
			
		||||
# playwright is installed at Dockerfile build time because it's not available on all platforms
 | 
			
		||||
 | 
			
		||||
# experimental release
 | 
			
		||||
pyppeteer-ng==2.0.0rc5
 | 
			
		||||
pyppeteerstealth>=0.0.4
 | 
			
		||||
 | 
			
		||||
# Include pytest, so if theres a support issue we can ask them to run these tests on their setup
 | 
			
		||||
pytest ~=7.2
 | 
			
		||||
@@ -85,3 +84,5 @@ pytest-flask ~=1.2
 | 
			
		||||
jsonschema==4.17.3
 | 
			
		||||
 | 
			
		||||
loguru
 | 
			
		||||
# Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096
 | 
			
		||||
greenlet >= 3.0.3
 | 
			
		||||
		Reference in New Issue
	
	Block a user