Compare commits
	
		
			246 Commits
		
	
	
		
			bug/RSS-fe
			...
			ui-long-li
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ebe0edf029 | ||
|   | 693f1e6d4f | ||
|   | 87726e0bb2 | ||
|   | 72222158e9 | ||
|   | 1814924c19 | ||
|   | 8aae4197d7 | ||
|   | 3a8a41a3ff | ||
|   | 64caeea491 | ||
|   | 3838bff397 | ||
|   | 55ea983bda | ||
|   | b4d79839bf | ||
|   | 0b8c3add34 | ||
|   | 51d57f0963 | ||
|   | 6d932149e3 | ||
|   | 2c764e8f84 | ||
|   | 07765b0d38 | ||
|   | 7c3faa8e38 | ||
|   | 4624974b91 | ||
|   | 991841f1f9 | ||
|   | e3db324698 | ||
|   | 0988bef2cd | ||
|   | 5b281f2c34 | ||
|   | a224f64cd6 | ||
|   | 7ee97ae37f | ||
|   | 69756f20f2 | ||
|   | 326b7aacbb | ||
|   | fde7b3fd97 | ||
|   | 9d04cb014a | ||
|   | 5b530ff61c | ||
|   | c98536ace4 | ||
|   | 463747d3b7 | ||
|   | 791bdb42aa | ||
|   | ce6c2737a8 | ||
|   | ade9e1138b | ||
|   | 68d5178367 | ||
|   | 41dc57aee3 | ||
|   | 943704cd04 | ||
|   | 883561f979 | ||
|   | 35d44c8277 | ||
|   | d07d7a1b18 | ||
|   | f066a1c38f | ||
|   | d0d191a7d1 | ||
|   | d7482c8d6a | ||
|   | bcf7417f63 | ||
|   | df6e835035 | ||
|   | ab28f20eba | ||
|   | 1174b95ab4 | ||
|   | a564475325 | ||
|   | 85d8d57997 | ||
|   | 359dcb63e3 | ||
|   | b043d477dc | ||
|   | 06bcfb28e5 | ||
|   | ca3b351bae | ||
|   | b7e0f0a5e4 | ||
|   | 61f0ac2937 | ||
|   | fca66eb558 | ||
|   | 359fc48fb4 | ||
|   | d0efeb9770 | ||
|   | 3416532cd6 | ||
|   | defc7a340e | ||
|   | c197c062e1 | ||
|   | 77b59809ca | ||
|   | f90b170e68 | ||
|   | c93ca1841c | ||
|   | 57f604dff1 | ||
|   | 8499468749 | ||
|   | 7f6a13ea6c | ||
|   | 9874f0cbc7 | ||
|   | 72834a42fd | ||
|   | 724cb17224 | ||
|   | 4eb4b401a1 | ||
|   | 5d40e16c73 | ||
|   | 492bbce6b6 | ||
|   | 0394a56be5 | ||
|   | 7839551d6b | ||
|   | 9c5588c791 | ||
|   | 5a43a350de | ||
|   | 3c31f023ce | ||
|   | 4cbcc59461 | ||
|   | 4be0260381 | ||
|   | 957a3c1c16 | ||
|   | 85897e0bf9 | ||
|   | 63095f70ea | ||
|   | 8d5b0b5576 | ||
|   | 1b077abd93 | ||
|   | 32ea1a8721 | ||
|   | fff32cef0d | ||
|   | 8fb146f3e4 | ||
|   | 770b0faa45 | ||
|   | f6faa90340 | ||
|   | 669fd3ae0b | ||
|   | 17d37fb626 | ||
|   | dfa7fc3a81 | ||
|   | cd467df97a | ||
|   | 71bc2fed82 | ||
|   | 738fcfe01c | ||
|   | 3ebb2ab9ba | ||
|   | ac98bc9144 | ||
|   | 3705ce6681 | ||
|   | f7ea99412f | ||
|   | d4715e2bc8 | ||
|   | 8567a83c47 | ||
|   | 77fdf59ae3 | ||
|   | 0e194aa4b4 | ||
|   | 2ba55bb477 | ||
|   | 4c759490da | ||
|   | 58a52c1f60 | ||
|   | 22638399c1 | ||
|   | e3381776f2 | ||
|   | 26e2f21a80 | ||
|   | b6009ae9ff | ||
|   | b046d6ef32 | ||
|   | e154a3cb7a | ||
|   | 1262700263 | ||
|   | 434c5813b9 | ||
|   | 0a3dc7d77b | ||
|   | a7e296de65 | ||
|   | bd0fbaaf27 | ||
|   | 0c111bd9ae | ||
|   | ed9ac0b7fb | ||
|   | 743a3069bb | ||
|   | fefc39427b | ||
|   | 2c6faa7c4e | ||
|   | 6168cd2899 | ||
|   | f3c7c969d8 | ||
|   | 1355c2a245 | ||
|   | 96cf1a06df | ||
|   | 019a4a0375 | ||
|   | db2f7b80ea | ||
|   | bfabd7b094 | ||
|   | d92dbfe765 | ||
|   | 67d2441334 | ||
|   | 3c30bc02d5 | ||
|   | dcb54117d5 | ||
|   | b1e32275dc | ||
|   | e2a6865932 | ||
|   | f04adb7202 | ||
|   | 1193a7f22c | ||
|   | 0b976827bb | ||
|   | 280e916033 | ||
|   | 5494e61a05 | ||
|   | e461c0b819 | ||
|   | d67c654f37 | ||
|   | 06ab34b6af | ||
|   | ba8676c4ba | ||
|   | 4899c1a4f9 | ||
|   | 9bff1582f7 | ||
|   | 269e3bb7c5 | ||
|   | 9976f3f969 | ||
|   | 1f250aa868 | ||
|   | 1c08d9f150 | ||
|   | 9942107016 | ||
|   | 1eb5726cbf | ||
|   | b3271ff7bb | ||
|   | f82d3b648a | ||
|   | 034b1330d4 | ||
|   | a7d005109f | ||
|   | 048c355e04 | ||
|   | 4026575b0b | ||
|   | 8c466b4826 | ||
|   | 6f072b42e8 | ||
|   | e318253f31 | ||
|   | f0f2fe94ce | ||
|   | 26f5c56ba4 | ||
|   | a1c3107cd6 | ||
|   | 8fef3ff4ab | ||
|   | baa25c9f9e | ||
|   | 488699b7d4 | ||
|   | cf3a1ee3e3 | ||
|   | daae43e9f9 | ||
|   | cdeedaa65c | ||
|   | 3c9d2ded38 | ||
|   | 9f4364a130 | ||
|   | 5bd9eaf99d | ||
|   | b1c51c0a65 | ||
|   | 232bd92389 | ||
|   | e6173357a9 | ||
|   | f2b8888aff | ||
|   | 9c46f175f9 | ||
|   | 1f27865fdf | ||
|   | faa42d75e0 | ||
|   | 3b6e6d85bb | ||
|   | 30d6a272ce | ||
|   | 291700554e | ||
|   | a82fad7059 | ||
|   | c2fe5ae0d1 | ||
|   | 5beefdb7cc | ||
|   | 872bbba71c | ||
|   | d578de1a35 | ||
|   | cdc104be10 | ||
|   | dd0eeca056 | ||
|   | a95468be08 | ||
|   | ace44d0e00 | ||
|   | ebb8b88621 | ||
|   | 12fc2200de | ||
|   | 52d3d375ba | ||
|   | 08117089e6 | ||
|   | 2ba3a6d53f | ||
|   | 2f636553a9 | ||
|   | 0bde48b282 | ||
|   | fae1164c0b | ||
|   | 169c293143 | ||
|   | 46cb5cff66 | ||
|   | 05584ea886 | ||
|   | 176a591357 | ||
|   | 15569f9592 | ||
|   | 5f9e475fe0 | ||
|   | 34b8784f50 | ||
|   | 2b054ced8c | ||
|   | 6553980cd5 | ||
|   | 7c12c47204 | ||
|   | dbd9b470d7 | ||
|   | 83555a9991 | ||
|   | 5bfdb28bd2 | ||
|   | 31a6a6717b | ||
|   | 7da32f9ac3 | ||
|   | bb732d3d2e | ||
|   | 485e55f9ed | ||
|   | 601a20ea49 | ||
|   | 76996b9eb8 | ||
|   | fba2b1a39d | ||
|   | 4a91505af5 | ||
|   | 4841c79b4c | ||
|   | 2ba00d2e1d | ||
|   | 19c96f4bdd | ||
|   | 82b900fbf4 | ||
|   | 358a365303 | ||
|   | a07ca4b136 | ||
|   | ba8cf2c8cf | ||
|   | 3106b6688e | ||
|   | 2c83845dac | ||
|   | 111266d6fa | ||
|   | ead610151f | ||
|   | 7e1e763989 | ||
|   | 327cc4af34 | ||
|   | 6008ff516e | ||
|   | cdcf4b353f | ||
|   | 1ab70f8e86 | ||
|   | 8227c012a7 | ||
|   | c113d5fb24 | ||
|   | 8c8d4066d7 | ||
|   | 277dc9e1c1 | ||
|   | fc0fd1ce9d | ||
|   | bd6127728a | ||
|   | 4101ae00c6 | ||
|   | 62f14df3cb | 
							
								
								
									
										16
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -7,6 +7,20 @@ assignees: 'dgtlmoon' | |||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
|  | **DO NOT USE THIS FORM TO REPORT THAT A PARTICULAR WEBSITE IS NOT SCRAPING/WATCHING AS EXPECTED** | ||||||
|  |  | ||||||
|  | This form is only for direct bugs and feature requests todo directly with the software. | ||||||
|  |  | ||||||
|  | Please report watched websites (full URL and _any_ settings) that do not work with changedetection.io as expected [**IN THE DISCUSSION FORUMS**](https://github.com/dgtlmoon/changedetection.io/discussions) or your report will be deleted | ||||||
|  |  | ||||||
|  | CONSIDER TAKING OUT A SUBSCRIPTION FOR A SMALL PRICE PER MONTH, YOU GET THE BENEFIT OF USING OUR PAID PROXIES AND FURTHERING THE DEVELOPMENT OF CHANGEDETECTION.IO | ||||||
|  |  | ||||||
|  | THANK YOU | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| **Describe the bug** | **Describe the bug** | ||||||
| A clear and concise description of what the bug is. | A clear and concise description of what the bug is. | ||||||
|  |  | ||||||
| @@ -21,7 +35,7 @@ Steps to reproduce the behavior: | |||||||
| 3. Scroll down to '....' | 3. Scroll down to '....' | ||||||
| 4. See error | 4. See error | ||||||
|  |  | ||||||
| ! ALWAYS INCLUDE AN EXAMPLE URL WHERE IT IS POSSIBLE TO RE-CREATE THE ISSUE ! | ! ALWAYS INCLUDE AN EXAMPLE URL WHERE IT IS POSSIBLE TO RE-CREATE THE ISSUE - USE THE 'SHARE WATCH' FEATURE AND PASTE IN THE SHARE-LINK! | ||||||
|  |  | ||||||
| **Expected behavior** | **Expected behavior** | ||||||
| A clear and concise description of what you expected to happen. | A clear and concise description of what you expected to happen. | ||||||
|   | |||||||
							
								
								
									
										31
									
								
								.github/test/Dockerfile-alpine
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,31 @@ | |||||||
|  | # Taken from https://github.com/linuxserver/docker-changedetection.io/blob/main/Dockerfile | ||||||
|  | # Test that we can still build on Alpine (musl modified libc https://musl.libc.org/) | ||||||
|  | # Some packages wont install via pypi because they dont have a wheel available under this architecture. | ||||||
|  |  | ||||||
|  | FROM ghcr.io/linuxserver/baseimage-alpine:3.16 | ||||||
|  | ENV PYTHONUNBUFFERED=1 | ||||||
|  |  | ||||||
|  | COPY requirements.txt /requirements.txt | ||||||
|  |  | ||||||
|  | RUN \ | ||||||
|  |   apk add --update --no-cache --virtual=build-dependencies \ | ||||||
|  |     cargo \ | ||||||
|  |     g++ \ | ||||||
|  |     gcc \ | ||||||
|  |     libc-dev \ | ||||||
|  |     libffi-dev \ | ||||||
|  |     libxslt-dev \ | ||||||
|  |     make \ | ||||||
|  |     openssl-dev \ | ||||||
|  |     py3-wheel \ | ||||||
|  |     python3-dev \ | ||||||
|  |     zlib-dev && \ | ||||||
|  |   apk add --update --no-cache \ | ||||||
|  |     libxslt \ | ||||||
|  |     python3 \ | ||||||
|  |     py3-pip && \ | ||||||
|  |   echo "**** pip3 install test of changedetection.io ****" && \ | ||||||
|  |   pip3 install -U pip wheel setuptools && \ | ||||||
|  |   pip3 install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/alpine-3.16/ -r /requirements.txt && \ | ||||||
|  |   apk del --purge \ | ||||||
|  |     build-dependencies | ||||||
							
								
								
									
										15
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -85,8 +85,8 @@ jobs: | |||||||
|           version: latest |           version: latest | ||||||
|           driver-opts: image=moby/buildkit:master |           driver-opts: image=moby/buildkit:master | ||||||
|  |  | ||||||
|       # master always builds :latest |       # master branch -> :dev container tag | ||||||
|       - name: Build and push :latest |       - name: Build and push :dev | ||||||
|         id: docker_build |         id: docker_build | ||||||
|         if: ${{ github.ref }} == "refs/heads/master" |         if: ${{ github.ref }} == "refs/heads/master" | ||||||
|         uses: docker/build-push-action@v2 |         uses: docker/build-push-action@v2 | ||||||
| @@ -95,12 +95,12 @@ jobs: | |||||||
|           file: ./Dockerfile |           file: ./Dockerfile | ||||||
|           push: true |           push: true | ||||||
|           tags: | |           tags: | | ||||||
|             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:latest,ghcr.io/${{ github.repository }}:latest |             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:dev,ghcr.io/${{ github.repository }}:dev | ||||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 |           platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 | ||||||
|           cache-from: type=local,src=/tmp/.buildx-cache |           cache-from: type=local,src=/tmp/.buildx-cache | ||||||
|           cache-to: type=local,dest=/tmp/.buildx-cache |           cache-to: type=local,dest=/tmp/.buildx-cache | ||||||
|  |  | ||||||
|       # A new tagged release is required, which builds :tag |       # A new tagged release is required, which builds :tag and :latest | ||||||
|       - name: Build and push :tag |       - name: Build and push :tag | ||||||
|         id: docker_build_tag_release |         id: docker_build_tag_release | ||||||
|         if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.') |         if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.') | ||||||
| @@ -110,7 +110,10 @@ jobs: | |||||||
|           file: ./Dockerfile |           file: ./Dockerfile | ||||||
|           push: true |           push: true | ||||||
|           tags: | |           tags: | | ||||||
|             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:${{ github.event.release.tag_name }},ghcr.io/dgtlmoon/changedetection.io:${{ github.event.release.tag_name }} |             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:${{ github.event.release.tag_name }} | ||||||
|  |             ghcr.io/dgtlmoon/changedetection.io:${{ github.event.release.tag_name }} | ||||||
|  |             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:latest | ||||||
|  |             ghcr.io/dgtlmoon/changedetection.io:latest | ||||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 |           platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 | ||||||
|           cache-from: type=local,src=/tmp/.buildx-cache |           cache-from: type=local,src=/tmp/.buildx-cache | ||||||
|           cache-to: type=local,dest=/tmp/.buildx-cache |           cache-to: type=local,dest=/tmp/.buildx-cache | ||||||
| @@ -125,5 +128,3 @@ jobs: | |||||||
|           key: ${{ runner.os }}-buildx-${{ github.sha }} |           key: ${{ runner.os }}-buildx-${{ github.sha }} | ||||||
|           restore-keys: | |           restore-keys: | | ||||||
|             ${{ runner.os }}-buildx- |             ${{ runner.os }}-buildx- | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										66
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,66 @@ | |||||||
|  | name: ChangeDetection.io Container Build Test | ||||||
|  |  | ||||||
|  | # Triggers the workflow on push or pull request events | ||||||
|  |  | ||||||
|  | # This line doesnt work, even tho it is the documented one | ||||||
|  | #on: [push, pull_request] | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   push: | ||||||
|  |     paths: | ||||||
|  |       - requirements.txt | ||||||
|  |       - Dockerfile | ||||||
|  |  | ||||||
|  |   pull_request: | ||||||
|  |     paths: | ||||||
|  |       - requirements.txt | ||||||
|  |       - Dockerfile | ||||||
|  |  | ||||||
|  |   # Changes to requirements.txt packages and Dockerfile may or may not always be compatible with arm etc, so worth testing | ||||||
|  |   # @todo: some kind of path filter for requirements.txt and Dockerfile | ||||||
|  | jobs: | ||||||
|  |   test-container-build: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |         - uses: actions/checkout@v2 | ||||||
|  |         - name: Set up Python 3.9 | ||||||
|  |           uses: actions/setup-python@v2 | ||||||
|  |           with: | ||||||
|  |             python-version: 3.9 | ||||||
|  |  | ||||||
|  |         # Just test that the build works, some libraries won't compile on ARM/rPi etc | ||||||
|  |         - name: Set up QEMU | ||||||
|  |           uses: docker/setup-qemu-action@v1 | ||||||
|  |           with: | ||||||
|  |             image: tonistiigi/binfmt:latest | ||||||
|  |             platforms: all | ||||||
|  |  | ||||||
|  |         - name: Set up Docker Buildx | ||||||
|  |           id: buildx | ||||||
|  |           uses: docker/setup-buildx-action@v1 | ||||||
|  |           with: | ||||||
|  |             install: true | ||||||
|  |             version: latest | ||||||
|  |             driver-opts: image=moby/buildkit:master | ||||||
|  |  | ||||||
|  |         # https://github.com/dgtlmoon/changedetection.io/pull/1067 | ||||||
|  |         # Check we can still build under alpine/musl | ||||||
|  |         - name: Test that the docker containers can build (musl via alpine check) | ||||||
|  |           id: docker_build_musl | ||||||
|  |           uses: docker/build-push-action@v2 | ||||||
|  |           with: | ||||||
|  |             context: ./ | ||||||
|  |             file: ./.github/test/Dockerfile-alpine | ||||||
|  |             platforms: linux/amd64,linux/arm64 | ||||||
|  |  | ||||||
|  |         - name: Test that the docker containers can build | ||||||
|  |           id: docker_build | ||||||
|  |           uses: docker/build-push-action@v2 | ||||||
|  |           # https://github.com/docker/build-push-action#customizing | ||||||
|  |           with: | ||||||
|  |             context: ./ | ||||||
|  |             file: ./Dockerfile | ||||||
|  |             platforms: linux/arm/v7,linux/arm/v6,linux/amd64,linux/arm64, | ||||||
|  |             cache-from: type=local,src=/tmp/.buildx-cache | ||||||
|  |             cache-to: type=local,dest=/tmp/.buildx-cache | ||||||
|  |  | ||||||
							
								
								
									
										12
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,28 +1,25 @@ | |||||||
| name: ChangeDetection.io Test | name: ChangeDetection.io App Test | ||||||
|  |  | ||||||
| # Triggers the workflow on push or pull request events | # Triggers the workflow on push or pull request events | ||||||
| on: [push, pull_request] | on: [push, pull_request] | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   test-build: |   test-application: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|  |  | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|       - name: Set up Python 3.9 |       - name: Set up Python 3.9 | ||||||
|         uses: actions/setup-python@v2 |         uses: actions/setup-python@v2 | ||||||
|         with: |         with: | ||||||
|           python-version: 3.9 |           python-version: 3.9 | ||||||
|  |  | ||||||
|       - name: Show env vars |  | ||||||
|         run: set |  | ||||||
|  |  | ||||||
|       - name: Install dependencies |       - name: Install dependencies | ||||||
|         run: | |         run: | | ||||||
|           python -m pip install --upgrade pip |           python -m pip install --upgrade pip | ||||||
|           pip install flake8 pytest |           pip install flake8 pytest | ||||||
|           if [ -f requirements.txt ]; then pip install -r requirements.txt; fi |           if [ -f requirements.txt ]; then pip install -r requirements.txt; fi | ||||||
|           if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi |           if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi | ||||||
|  |  | ||||||
|       - name: Lint with flake8 |       - name: Lint with flake8 | ||||||
|         run: | |         run: | | ||||||
|           # stop the build if there are Python syntax errors or undefined names |           # stop the build if there are Python syntax errors or undefined names | ||||||
| @@ -39,7 +36,4 @@ jobs: | |||||||
|           # Each test is totally isolated and performs its own cleanup/reset |           # Each test is totally isolated and performs its own cleanup/reset | ||||||
|           cd changedetectionio; ./run_all_tests.sh |           cd changedetectionio; ./run_all_tests.sh | ||||||
|  |  | ||||||
|       # https://github.com/docker/build-push-action/blob/master/docs/advanced/test-before-push.md ? |  | ||||||
|       # https://github.com/docker/buildx/issues/59 ? Needs to be one platform? |  | ||||||
|  |  | ||||||
|       # https://github.com/docker/buildx/issues/495#issuecomment-918925854 |  | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -8,5 +8,7 @@ __pycache__ | |||||||
| build | build | ||||||
| dist | dist | ||||||
| venv | venv | ||||||
|  | test-datastore/* | ||||||
|  | test-datastore | ||||||
| *.egg-info* | *.egg-info* | ||||||
| .vscode/settings.json | .vscode/settings.json | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ Otherwise, it's always best to PR into the `dev` branch. | |||||||
|  |  | ||||||
| Please be sure that all new functionality has a matching test! | Please be sure that all new functionality has a matching test! | ||||||
|  |  | ||||||
| Use `pytest` to validate/test, you can run the existing tests as `pytest tests/test_notifications.py` for example | Use `pytest` to validate/test, you can run the existing tests as `pytest tests/test_notification.py` for example | ||||||
|  |  | ||||||
| ``` | ``` | ||||||
| pip3 install -r requirements-dev | pip3 install -r requirements-dev | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						| @@ -5,13 +5,15 @@ FROM python:3.8-slim as builder | |||||||
| ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1 | ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1 | ||||||
|  |  | ||||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||||
|     libssl-dev \ |     g++ \ | ||||||
|     libffi-dev \ |  | ||||||
|     gcc \ |     gcc \ | ||||||
|     libc-dev \ |     libc-dev \ | ||||||
|  |     libffi-dev \ | ||||||
|  |     libjpeg-dev \ | ||||||
|  |     libssl-dev \ | ||||||
|     libxslt-dev \ |     libxslt-dev \ | ||||||
|     zlib1g-dev \ |     make \ | ||||||
|     g++ |     zlib1g-dev | ||||||
|  |  | ||||||
| RUN mkdir /install | RUN mkdir /install | ||||||
| WORKDIR /install | WORKDIR /install | ||||||
| @@ -22,7 +24,8 @@ RUN pip install --target=/dependencies -r /requirements.txt | |||||||
|  |  | ||||||
| # Playwright is an alternative to Selenium | # Playwright is an alternative to Selenium | ||||||
| # Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing | # Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing | ||||||
| RUN pip install --target=/dependencies playwright~=1.20 \ | # https://github.com/dgtlmoon/changedetection.io/pull/1067 also musl/alpine (not supported) | ||||||
|  | RUN pip install --target=/dependencies playwright~=1.27.1 \ | ||||||
|     || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled." |     || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled." | ||||||
|  |  | ||||||
| # Final image stage | # Final image stage | ||||||
| @@ -34,13 +37,14 @@ ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1 | |||||||
|  |  | ||||||
| # Re #93, #73, excluding rustc (adds another 430Mb~) | # Re #93, #73, excluding rustc (adds another 430Mb~) | ||||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||||
|     libssl-dev \ |     g++ \ | ||||||
|     libffi-dev \ |  | ||||||
|     gcc \ |     gcc \ | ||||||
|     libc-dev \ |     libc-dev \ | ||||||
|  |     libffi-dev \ | ||||||
|  |     libjpeg-dev \ | ||||||
|  |     libssl-dev \ | ||||||
|     libxslt-dev \ |     libxslt-dev \ | ||||||
|     zlib1g-dev \ |     zlib1g-dev | ||||||
|     g++ |  | ||||||
|  |  | ||||||
| # https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops | # https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops | ||||||
| ENV PYTHONUNBUFFERED=1 | ENV PYTHONUNBUFFERED=1 | ||||||
| @@ -58,6 +62,7 @@ EXPOSE 5000 | |||||||
|  |  | ||||||
| # The actual flask app | # The actual flask app | ||||||
| COPY changedetectionio /app/changedetectionio | COPY changedetectionio /app/changedetectionio | ||||||
|  |  | ||||||
| # The eventlet server wrapper | # The eventlet server wrapper | ||||||
| COPY changedetection.py /app/changedetection.py | COPY changedetection.py /app/changedetection.py | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,7 +2,12 @@ recursive-include changedetectionio/api * | |||||||
| recursive-include changedetectionio/templates * | recursive-include changedetectionio/templates * | ||||||
| recursive-include changedetectionio/static * | recursive-include changedetectionio/static * | ||||||
| recursive-include changedetectionio/model * | recursive-include changedetectionio/model * | ||||||
|  | recursive-include changedetectionio/tests * | ||||||
|  | recursive-include changedetectionio/res * | ||||||
|  | prune changedetectionio/static/package-lock.json | ||||||
|  | prune changedetectionio/static/styles/node_modules | ||||||
|  | prune changedetectionio/static/styles/package-lock.json | ||||||
| include changedetection.py | include changedetection.py | ||||||
| global-exclude *.pyc | global-exclude *.pyc | ||||||
| global-exclude node_modules | global-exclude node_modules | ||||||
| global-exclude venv | global-exclude venv | ||||||
|   | |||||||
| @@ -1,45 +1,48 @@ | |||||||
| #  changedetection.io | ## Web Site Change Detection, Monitoring and Notification. | ||||||
|  |  | ||||||
| <a href="https://hub.docker.com/r/dgtlmoon/changedetection.io" target="_blank" title="Change detection docker hub"> |  | ||||||
|   <img src="https://img.shields.io/docker/pulls/dgtlmoon/changedetection.io" alt="Docker Pulls"/> |  | ||||||
| </a> |  | ||||||
| <a href="https://hub.docker.com/r/dgtlmoon/changedetection.io" target="_blank" title="Change detection docker hub"> |  | ||||||
|   <img src="https://img.shields.io/github/v/release/dgtlmoon/changedetection.io" alt="Change detection latest tag version"/>  |  | ||||||
| </a> |  | ||||||
|  |  | ||||||
| ## Self-hosted open source change monitoring of web pages. | Live your data-life pro-actively, track website content changes and receive notifications via Discord, Email, Slack, Telegram and 70+ more | ||||||
|  |  | ||||||
| _Know when web pages change! Stay ontop of new information!_  | [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring"  title="Self-hosted web page change monitoring"  />](https://lemonade.changedetection.io/start?src=pip) | ||||||
|  |  | ||||||
| Live your data-life *pro-actively* instead of *re-actively*, do not rely on manipulative social media for consuming important information. |  | ||||||
|  |  | ||||||
|  |  | ||||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring"  title="Self-hosted web page change monitoring"  /> | [**Don't have time? Let us host it for you! try our extremely affordable subscription use our proxies and support!**](https://lemonade.changedetection.io/start)  | ||||||
|  |  | ||||||
|  |  | ||||||
| **Get your own private instance now! Let us host it for you!** |  | ||||||
|  |  | ||||||
| [**Try our $6.99/month subscription - unlimited checks, watches and notifications!**](https://lemonade.changedetection.io/start), choose from different geographical locations, let us handle everything for you.  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| #### Example use cases | #### Example use cases | ||||||
|  |  | ||||||
| Know when ... | - Products and services have a change in pricing | ||||||
|  | - _Out of stock notification_ and _Back In stock notification_ | ||||||
| - Government department updates (changes are often only on their websites) | - Governmental department updates (changes are often only on their websites) | ||||||
| - Local government news (changes are often only on their websites) |  | ||||||
| - New software releases, security advisories when you're not on their mailing list. | - New software releases, security advisories when you're not on their mailing list. | ||||||
| - Festivals with changes | - Festivals with changes | ||||||
| - Realestate listing changes | - Realestate listing changes | ||||||
|  | - Know when your favourite whiskey is on sale, or other special deals are announced before anyone else | ||||||
| - COVID related news from government websites | - COVID related news from government websites | ||||||
|  | - University/organisation news from their website | ||||||
| - Detect and monitor changes in JSON API responses  | - Detect and monitor changes in JSON API responses  | ||||||
| - API monitoring and alerting | - JSON API monitoring and alerting | ||||||
|  | - Changes in legal and other documents | ||||||
|  | - Trigger API calls via notifications when text appears on a website | ||||||
|  | - Glue together APIs using the JSON filter and JSON notifications | ||||||
|  | - Create RSS feeds based on changes in web content | ||||||
|  | - Monitor HTML source code for unexpected changes, strengthen your PCI compliance | ||||||
|  | - You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product) | ||||||
|  |  | ||||||
|  | _Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_ | ||||||
|  |  | ||||||
|  | #### Key Features | ||||||
|  |  | ||||||
|  | - Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions! | ||||||
|  | - Target elements with xPath and CSS Selectors, Easily monitor complex JSON with JSONPath or jq | ||||||
|  | - Switch between fast non-JS and Chrome JS based "fetchers" | ||||||
|  | - Easily specify how often a site should be checked | ||||||
|  | - Execute JS before extracting text (Good for logging in, see examples in the UI!) | ||||||
|  | - Override Request Headers, Specify `POST` or `GET` and other methods | ||||||
|  | - Use the "Visual Selector" to help target specific elements | ||||||
|  |  | ||||||
| **Get monitoring now!** |  | ||||||
|  |  | ||||||
| ```bash | ```bash | ||||||
| $ pip3 install changedetection.io    | $ pip3 install changedetection.io | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| Specify a target for the *datastore path* with `-d` (required) and a *listening port* with `-p` (defaults to `5000`) | Specify a target for the *datastore path* with `-d` (required) and a *listening port* with `-p` (defaults to `5000`) | ||||||
| @@ -51,17 +54,5 @@ $ changedetection.io -d /path/to/empty/data/dir -p 5000 | |||||||
|  |  | ||||||
| Then visit http://127.0.0.1:5000 , You should now be able to access the UI. | Then visit http://127.0.0.1:5000 , You should now be able to access the UI. | ||||||
|  |  | ||||||
| ### Features |  | ||||||
| - Website monitoring |  | ||||||
| - Change detection of content and analyses |  | ||||||
| - Filters on change (Select by CSS or JSON) |  | ||||||
| - Triggers (Wait for text, wait for regex) |  | ||||||
| - Notification support |  | ||||||
| - JSON API Monitoring |  | ||||||
| - Parse JSON embedded in HTML |  | ||||||
| - (Reverse) Proxy support |  | ||||||
| - Javascript support via WebDriver |  | ||||||
| - RaspberriPi (arm v6/v7/64 support) |  | ||||||
|  |  | ||||||
| See https://github.com/dgtlmoon/changedetection.io for more information. | See https://github.com/dgtlmoon/changedetection.io for more information. | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										105
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,38 +1,53 @@ | |||||||
| #  changedetection.io | ## Web Site Change Detection, Monitoring and Notification. | ||||||
|  |  | ||||||
|  | _Live your data-life pro-actively, Detect website changes and perform meaningful actions, trigger notifications via Discord, Email, Slack, Telegram, API calls and many more._ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring"  title="Self-hosted web page change monitoring"  />](https://lemonade.changedetection.io/start?src=github) | ||||||
|  |  | ||||||
| [![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md) | [![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| ## Self-Hosted, Open Source, Change Monitoring of Web Pages | [**Don't have time? Let us host it for you! try our $6.99/month subscription - use our proxies and support!**](https://lemonade.changedetection.io/start) , _half the price of other website change monitoring services and comes with unlimited watches & checks!_ | ||||||
|  |  | ||||||
| _Know when web pages change! Stay ontop of new information!_  | - Chrome browser included. | ||||||
|  | - Super fast, no registration needed setup. | ||||||
| Live your data-life *pro-actively* instead of *re-actively*. | - Get started watching and receiving website change notifications straight away. | ||||||
|  |  | ||||||
| Free, Open-source web page monitoring, notification and change detection. Don't have time? [**Try our $6.99/month subscription - unlimited checks and watches!**](https://lemonade.changedetection.io/start) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring"  title="Self-hosted web page change monitoring"  />](https://lemonade.changedetection.io/start) | ### Target specific parts of the webpage using the Visual Selector tool. | ||||||
|  |  | ||||||
|  | Available when connected to a <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher">playwright content fetcher</a> (included as part of our subscription service) | ||||||
|  |  | ||||||
|  | [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/visualselector-anim.gif" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference "  title="Self-hosted web page change monitoring context difference " />](https://lemonade.changedetection.io/start?src=github) | ||||||
|  |  | ||||||
|  | ### Easily see what changed, examine by word, line, or individual character. | ||||||
|  |  | ||||||
|  | [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot-diff.png" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference "  title="Self-hosted web page change monitoring context difference " />](https://lemonade.changedetection.io/start?src=github) | ||||||
|  |  | ||||||
|  |  | ||||||
| **Get your own private instance now! Let us host it for you!** | ### Perform interactive browser steps | ||||||
|  |  | ||||||
| [**Try our $6.99/month subscription - unlimited checks and watches!**](https://lemonade.changedetection.io/start) , _half the price of other website change monitoring services and comes with unlimited watches & checks!_ | Fill in text boxes, click buttons and more, setup your changedetection scenario.  | ||||||
|  |  | ||||||
|  | Using the **Browser Steps** configuration, add basic steps before performing change detection, such as logging into websites, adding a product to a cart, accept cookie logins, entering dates and refining searches. | ||||||
|  |  | ||||||
|  | [<img src="docs/browsersteps-anim.gif" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference "  title="Website change detection with interactive browser steps, login, cookies etc" />](https://lemonade.changedetection.io/start?src=github) | ||||||
|  |  | ||||||
|  | After **Browser Steps** have been run, then visit the **Visual Selector** tab to refine the content you're interested in. | ||||||
|  | Requires Playwright to be enabled. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ### Example use cases | ||||||
| - Automatic Updates, Automatic Backups, No Heroku "paused application", don't miss a change! |  | ||||||
| - Javascript browser included |  | ||||||
| - Unlimited checks and watches! |  | ||||||
|  |  | ||||||
|  |  | ||||||
| #### Example use cases |  | ||||||
|  |  | ||||||
| - Products and services have a change in pricing | - Products and services have a change in pricing | ||||||
|  | - _Out of stock notification_ and _Back In stock notification_ | ||||||
| - Governmental department updates (changes are often only on their websites) | - Governmental department updates (changes are often only on their websites) | ||||||
| - New software releases, security advisories when you're not on their mailing list. | - New software releases, security advisories when you're not on their mailing list. | ||||||
| - Festivals with changes | - Festivals with changes | ||||||
| - Realestate listing changes | - Realestate listing changes | ||||||
|  | - Know when your favourite whiskey is on sale, or other special deals are announced before anyone else | ||||||
| - COVID related news from government websites | - COVID related news from government websites | ||||||
| - University/organisation news from their website | - University/organisation news from their website | ||||||
| - Detect and monitor changes in JSON API responses  | - Detect and monitor changes in JSON API responses  | ||||||
| @@ -43,38 +58,44 @@ Free, Open-source web page monitoring, notification and change detection. Don't | |||||||
| - Create RSS feeds based on changes in web content | - Create RSS feeds based on changes in web content | ||||||
| - Monitor HTML source code for unexpected changes, strengthen your PCI compliance | - Monitor HTML source code for unexpected changes, strengthen your PCI compliance | ||||||
| - You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product) | - You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product) | ||||||
|  | - Get notified when certain keywords appear in Twitter search results | ||||||
|  | - Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords. | ||||||
|  |  | ||||||
| _Need an actual Chrome runner with Javascript support? We support fetching via WebDriver!</a>_ | _Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_ | ||||||
|  |  | ||||||
| ## Screenshots | #### Key Features | ||||||
|  |  | ||||||
| ### Examine differences in content. | - Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions! | ||||||
|  | - Target elements with xPath and CSS Selectors, Easily monitor complex JSON with JSONPath or jq | ||||||
|  | - Switch between fast non-JS and Chrome JS based "fetchers" | ||||||
|  | - Easily specify how often a site should be checked | ||||||
|  | - Execute JS before extracting text (Good for logging in, see examples in the UI!) | ||||||
|  | - Override Request Headers, Specify `POST` or `GET` and other methods | ||||||
|  | - Use the "Visual Selector" to help target specific elements | ||||||
|  | - Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration) | ||||||
|  | - Send a screenshot with the notification when a change is detected in the web page | ||||||
|  |  | ||||||
| Easily see what changed, examine by word, line, or individual character. | We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) global proxy services, Bright Data will match any first deposit up to $100 using our signup link. | ||||||
|  |  | ||||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot-diff.png" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference "  title="Self-hosted web page change monitoring context difference " /> |  | ||||||
|  |  | ||||||
| Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/ | Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/ | ||||||
|  |  | ||||||
| ### Target elements with the Visual Selector tool. |  | ||||||
|  |  | ||||||
| Available when connected to a <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher">playwright content fetcher</a> (available also as part of our subscription service) |  | ||||||
|  |  | ||||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/visualselector-anim.gif" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference "  title="Self-hosted web page change monitoring context difference " /> |  | ||||||
|  |  | ||||||
| ## Installation | ## Installation | ||||||
|  |  | ||||||
| ### Docker | ### Docker | ||||||
|  |  | ||||||
| With Docker composer, just clone this repository and.. | With Docker composer, just clone this repository and.. | ||||||
|  |  | ||||||
| ```bash | ```bash | ||||||
| $ docker-compose up -d | $ docker-compose up -d | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| Docker standalone | Docker standalone | ||||||
| ```bash | ```bash | ||||||
| $ docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io | $ docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | `:latest` tag is our latest stable release, `:dev` tag is our bleeding edge `master` branch. | ||||||
|  |  | ||||||
| ### Windows | ### Windows | ||||||
|  |  | ||||||
| See the install instructions at the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Microsoft-Windows | See the install instructions at the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Microsoft-Windows | ||||||
| @@ -97,8 +118,8 @@ _Now with per-site configurable support for using a fast built in HTTP fetcher o | |||||||
| ### Docker | ### Docker | ||||||
| ``` | ``` | ||||||
| docker pull dgtlmoon/changedetection.io | docker pull dgtlmoon/changedetection.io | ||||||
| docker kill $(docker ps -a|grep changedetection.io|awk '{print $1}') | docker kill $(docker ps -a -f name=changedetection.io -q) | ||||||
| docker rm $(docker ps -a|grep changedetection.io|awk '{print $1}') | docker rm $(docker ps -a -f name=changedetection.io -q) | ||||||
| docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io | docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| @@ -112,9 +133,9 @@ See the wiki for more information https://github.com/dgtlmoon/changedetection.io | |||||||
|  |  | ||||||
|  |  | ||||||
| ## Filters | ## Filters | ||||||
| XPath, JSONPath and CSS support comes baked in! You can be as specific as you need, use XPath exported from various XPath element query creation tools. |  | ||||||
|  |  | ||||||
| (We support LXML re:test, re:math and re:replace.) | XPath, JSONPath, jq, and CSS support comes baked in! You can be as specific as you need, use XPath exported from various XPath element query creation tools.  | ||||||
|  | (We support LXML `re:test`, `re:math` and `re:replace`.) | ||||||
|  |  | ||||||
| ## Notifications | ## Notifications | ||||||
|  |  | ||||||
| @@ -142,7 +163,7 @@ Now you can also customise your notification content! | |||||||
|  |  | ||||||
| ## JSON API Monitoring | ## JSON API Monitoring | ||||||
|  |  | ||||||
| Detect changes and monitor data in JSON API's by using the built-in JSONPath selectors as a filter / selector. | Detect changes and monitor data in JSON API's by using either JSONPath or jq to filter, parse, and restructure JSON as needed. | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -150,9 +171,17 @@ This will re-parse the JSON and apply formatting to the text, making it super ea | |||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ### JSONPath or jq? | ||||||
|  |  | ||||||
|  | For more complex parsing, filtering, and modifying of JSON data, jq is recommended due to the built-in operators and functions. Refer to the [documentation](https://stedolan.github.io/jq/manual/) for more specifc information on jq. | ||||||
|  |  | ||||||
|  | One big advantage of `jq` is that you can use logic in your JSON filter, such as filters to only show items that have a value greater than/less than etc. | ||||||
|  |  | ||||||
|  | See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/JSON-Selector-Filter-help for more information and examples | ||||||
|  |  | ||||||
| ### Parse JSON embedded in HTML! | ### Parse JSON embedded in HTML! | ||||||
|  |  | ||||||
| When you enable a `json:` filter, you can even automatically extract and parse embedded JSON inside a HTML page! Amazingly handy for sites that build content based on JSON, such as many e-commerce websites.  | When you enable a `json:` or `jq:` filter, you can even automatically extract and parse embedded JSON inside a HTML page! Amazingly handy for sites that build content based on JSON, such as many e-commerce websites.  | ||||||
|  |  | ||||||
| ``` | ``` | ||||||
| <html> | <html> | ||||||
| @@ -162,11 +191,11 @@ When you enable a `json:` filter, you can even automatically extract and parse e | |||||||
| </script> | </script> | ||||||
| ```   | ```   | ||||||
|  |  | ||||||
| `json:$.price` would give `23.50`, or you can extract the whole structure | `json:$.price` or `jq:.price` would give `23.50`, or you can extract the whole structure | ||||||
|  |  | ||||||
| ## Proxy configuration | ## Proxy Configuration | ||||||
|  |  | ||||||
| See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration | See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration , we also support using [BrightData proxy services where possible]( https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support) | ||||||
|  |  | ||||||
| ## Raspberry Pi support? | ## Raspberry Pi support? | ||||||
|  |  | ||||||
|   | |||||||
| @@ -6,6 +6,36 @@ | |||||||
| # Read more https://github.com/dgtlmoon/changedetection.io/wiki | # Read more https://github.com/dgtlmoon/changedetection.io/wiki | ||||||
|  |  | ||||||
| from changedetectionio import changedetection | from changedetectionio import changedetection | ||||||
|  | import multiprocessing | ||||||
|  | import signal | ||||||
|  | import os | ||||||
|  |  | ||||||
|  | def sigchld_handler(_signo, _stack_frame): | ||||||
|  |     import sys | ||||||
|  |     print('Shutdown: Got SIGCHLD') | ||||||
|  |     # https://stackoverflow.com/questions/40453496/python-multiprocessing-capturing-signals-to-restart-child-processes-or-shut-do | ||||||
|  |     pid, status = os.waitpid(-1, os.WNOHANG | os.WUNTRACED | os.WCONTINUED) | ||||||
|  |  | ||||||
|  |     print('Sub-process: pid %d status %d' % (pid, status)) | ||||||
|  |     if status != 0: | ||||||
|  |         sys.exit(1) | ||||||
|  |  | ||||||
|  |     raise SystemExit | ||||||
|  |  | ||||||
| if __name__ == '__main__': | if __name__ == '__main__': | ||||||
|     changedetection.main() |  | ||||||
|  |     #signal.signal(signal.SIGCHLD, sigchld_handler) | ||||||
|  |  | ||||||
|  |     # The only way I could find to get Flask to shutdown, is to wrap it and then rely on the subsystem issuing SIGTERM/SIGKILL | ||||||
|  |     parse_process = multiprocessing.Process(target=changedetection.main) | ||||||
|  |     parse_process.daemon = True | ||||||
|  |     parse_process.start() | ||||||
|  |     import time | ||||||
|  |  | ||||||
|  |     try: | ||||||
|  |         while True: | ||||||
|  |             time.sleep(1) | ||||||
|  |  | ||||||
|  |     except KeyboardInterrupt: | ||||||
|  |         #parse_process.terminate() not needed, because this process will issue it to the sub-process anyway | ||||||
|  |         print ("Exited - CTRL+C") | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								changedetectionio/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1 +1,2 @@ | |||||||
| test-datastore | test-datastore | ||||||
|  | package-lock.json | ||||||
|   | |||||||
| @@ -24,7 +24,7 @@ class Watch(Resource): | |||||||
|             abort(404, message='No watch exists with the UUID of {}'.format(uuid)) |             abort(404, message='No watch exists with the UUID of {}'.format(uuid)) | ||||||
|  |  | ||||||
|         if request.args.get('recheck'): |         if request.args.get('recheck'): | ||||||
|             self.update_q.put(uuid) |             self.update_q.put((1, uuid)) | ||||||
|             return "OK", 200 |             return "OK", 200 | ||||||
|  |  | ||||||
|         # Return without history, get that via another API call |         # Return without history, get that via another API call | ||||||
| @@ -100,7 +100,7 @@ class CreateWatch(Resource): | |||||||
|         extras = {'title': json_data['title'].strip()} if json_data.get('title') else {} |         extras = {'title': json_data['title'].strip()} if json_data.get('title') else {} | ||||||
|  |  | ||||||
|         new_uuid = self.datastore.add_watch(url=json_data['url'].strip(), tag=tag, extras=extras) |         new_uuid = self.datastore.add_watch(url=json_data['url'].strip(), tag=tag, extras=extras) | ||||||
|         self.update_q.put(new_uuid) |         self.update_q.put((1, new_uuid)) | ||||||
|         return {'uuid': new_uuid}, 201 |         return {'uuid': new_uuid}, 201 | ||||||
|  |  | ||||||
|     # Return concise list of available watches and some very basic info |     # Return concise list of available watches and some very basic info | ||||||
| @@ -113,12 +113,46 @@ class CreateWatch(Resource): | |||||||
|             list[k] = {'url': v['url'], |             list[k] = {'url': v['url'], | ||||||
|                        'title': v['title'], |                        'title': v['title'], | ||||||
|                        'last_checked': v['last_checked'], |                        'last_checked': v['last_checked'], | ||||||
|                        'last_changed': v['last_changed'], |                        'last_changed': v.last_changed, | ||||||
|                        'last_error': v['last_error']} |                        'last_error': v['last_error']} | ||||||
|  |  | ||||||
|         if request.args.get('recheck_all'): |         if request.args.get('recheck_all'): | ||||||
|             for uuid in self.datastore.data['watching'].keys(): |             for uuid in self.datastore.data['watching'].keys(): | ||||||
|                 self.update_q.put(uuid) |                 self.update_q.put((1, uuid)) | ||||||
|             return {'status': "OK"}, 200 |             return {'status': "OK"}, 200 | ||||||
|  |  | ||||||
|         return list, 200 |         return list, 200 | ||||||
|  |  | ||||||
|  | class SystemInfo(Resource): | ||||||
|  |     def __init__(self, **kwargs): | ||||||
|  |         # datastore is a black box dependency | ||||||
|  |         self.datastore = kwargs['datastore'] | ||||||
|  |         self.update_q = kwargs['update_q'] | ||||||
|  |  | ||||||
|  |     @auth.check_token | ||||||
|  |     def get(self): | ||||||
|  |         import time | ||||||
|  |         overdue_watches = [] | ||||||
|  |  | ||||||
|  |         # Check all watches and report which have not been checked but should have been | ||||||
|  |  | ||||||
|  |         for uuid, watch in self.datastore.data.get('watching', {}).items(): | ||||||
|  |             # see if now - last_checked is greater than the time that should have been | ||||||
|  |             # this is not super accurate (maybe they just edited it) but better than nothing | ||||||
|  |             t = watch.threshold_seconds() | ||||||
|  |             if not t: | ||||||
|  |                 # Use the system wide default | ||||||
|  |                 t = self.datastore.threshold_seconds | ||||||
|  |  | ||||||
|  |             time_since_check = time.time() - watch.get('last_checked') | ||||||
|  |  | ||||||
|  |             # Allow 5 minutes of grace time before we decide it's overdue | ||||||
|  |             if time_since_check - (5 * 60) > t: | ||||||
|  |                 overdue_watches.append(uuid) | ||||||
|  |  | ||||||
|  |         return { | ||||||
|  |                    'queue_size': self.update_q.qsize(), | ||||||
|  |                    'overdue_watches': overdue_watches, | ||||||
|  |                    'uptime': round(time.time() - self.datastore.start_time, 2), | ||||||
|  |                    'watch_count': len(self.datastore.data.get('watching', {})) | ||||||
|  |                }, 200 | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								changedetectionio/apprise_asset.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | |||||||
|  | import apprise | ||||||
|  |  | ||||||
|  | # Create our AppriseAsset and populate it with some of our new values: | ||||||
|  | # https://github.com/caronc/apprise/wiki/Development_API#the-apprise-asset-object | ||||||
|  | asset = apprise.AppriseAsset( | ||||||
|  |    image_url_logo='https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png' | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | asset.app_id = "changedetection.io" | ||||||
|  | asset.app_desc = "ChangeDetection.io best and simplest website monitoring and change detection" | ||||||
|  | asset.app_url = "https://changedetection.io" | ||||||
							
								
								
									
										0
									
								
								changedetectionio/blueprint/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										231
									
								
								changedetectionio/blueprint/browser_steps/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,231 @@ | |||||||
|  |  | ||||||
|  | # HORRIBLE HACK BUT WORKS :-) PR anyone? | ||||||
|  | # | ||||||
|  | # Why? | ||||||
|  | # `browsersteps_playwright_browser_interface.chromium.connect_over_cdp()` will only run once without async() | ||||||
|  | # - this flask app is not async() | ||||||
|  | # - browserless has a single timeout/keepalive which applies to the session made at .connect_over_cdp() | ||||||
|  | # | ||||||
|  | # So it means that we must unfortunately for now just keep a single timer since .connect_over_cdp() was run | ||||||
|  | # and know when that reaches timeout/keepalive :( when that time is up, restart the connection and tell the user | ||||||
|  | # that their time is up, insert another coin. (reload) | ||||||
|  | # | ||||||
|  | # Bigger picture | ||||||
|  | # - It's horrible that we have this click+wait deal, some nice socket.io solution using something similar | ||||||
|  | # to what the browserless debug UI already gives us would be smarter.. | ||||||
|  | # | ||||||
|  | # OR | ||||||
|  | # - Some API call that should be hacked into browserless or playwright that we can "/api/bump-keepalive/{session_id}/60" | ||||||
|  | # So we can tell it that we need more time (run this on each action) | ||||||
|  | # | ||||||
|  | # OR | ||||||
|  | # - use multiprocessing to bump this over to its own process and add some transport layer (queue/pipes) | ||||||
|  |  | ||||||
|  | from distutils.util import strtobool | ||||||
|  | from flask import Blueprint, request, make_response | ||||||
|  | from flask_login import login_required | ||||||
|  | import os | ||||||
|  | import logging | ||||||
|  | from changedetectionio.store import ChangeDetectionStore | ||||||
|  |  | ||||||
|  | browsersteps_live_ui_o = {} | ||||||
|  | browsersteps_playwright_browser_interface = None | ||||||
|  | browsersteps_playwright_browser_interface_browser = None | ||||||
|  | browsersteps_playwright_browser_interface_context = None | ||||||
|  | browsersteps_playwright_browser_interface_end_time = None | ||||||
|  | browsersteps_playwright_browser_interface_start_time = None | ||||||
|  |  | ||||||
|  | def cleanup_playwright_session(): | ||||||
|  |  | ||||||
|  |     global browsersteps_live_ui_o | ||||||
|  |     global browsersteps_playwright_browser_interface | ||||||
|  |     global browsersteps_playwright_browser_interface_browser | ||||||
|  |     global browsersteps_playwright_browser_interface_context | ||||||
|  |     global browsersteps_playwright_browser_interface_end_time | ||||||
|  |     global browsersteps_playwright_browser_interface_start_time | ||||||
|  |  | ||||||
|  |     browsersteps_live_ui_o = {} | ||||||
|  |     browsersteps_playwright_browser_interface = None | ||||||
|  |     browsersteps_playwright_browser_interface_browser = None | ||||||
|  |     browsersteps_playwright_browser_interface_end_time = None | ||||||
|  |     browsersteps_playwright_browser_interface_start_time = None | ||||||
|  |  | ||||||
|  |     print("Cleaning up old playwright session because time was up, calling .goodbye()") | ||||||
|  |     try: | ||||||
|  |         browsersteps_playwright_browser_interface_context.goodbye() | ||||||
|  |     except Exception as e: | ||||||
|  |         print ("Got exception in shutdown, probably OK") | ||||||
|  |         print (str(e)) | ||||||
|  |  | ||||||
|  |     browsersteps_playwright_browser_interface_context = None | ||||||
|  |  | ||||||
|  |     print ("Cleaning up old playwright session because time was up - done") | ||||||
|  |  | ||||||
|  | def construct_blueprint(datastore: ChangeDetectionStore): | ||||||
|  |  | ||||||
|  |     browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates") | ||||||
|  |  | ||||||
|  |     @login_required | ||||||
|  |     @browser_steps_blueprint.route("/browsersteps_update", methods=['GET', 'POST']) | ||||||
|  |     def browsersteps_ui_update(): | ||||||
|  |         import base64 | ||||||
|  |         import playwright._impl._api_types | ||||||
|  |         import time | ||||||
|  |  | ||||||
|  |         from changedetectionio.blueprint.browser_steps import browser_steps | ||||||
|  |  | ||||||
|  |         global browsersteps_live_ui_o, browsersteps_playwright_browser_interface_end_time | ||||||
|  |         global browsersteps_playwright_browser_interface_browser | ||||||
|  |         global browsersteps_playwright_browser_interface | ||||||
|  |         global browsersteps_playwright_browser_interface_start_time | ||||||
|  |  | ||||||
|  |         step_n = None | ||||||
|  |         remaining =0 | ||||||
|  |         uuid = request.args.get('uuid') | ||||||
|  |  | ||||||
|  |         browsersteps_session_id = request.args.get('browsersteps_session_id') | ||||||
|  |  | ||||||
|  |         if not browsersteps_session_id: | ||||||
|  |             return make_response('No browsersteps_session_id specified', 500) | ||||||
|  |  | ||||||
|  |         # Because we don't "really" run in a context manager ( we make the playwright interface global/long-living ) | ||||||
|  |         # We need to manage the shutdown when the time is up | ||||||
|  |         if browsersteps_playwright_browser_interface_end_time: | ||||||
|  |             remaining = browsersteps_playwright_browser_interface_end_time-time.time() | ||||||
|  |             if browsersteps_playwright_browser_interface_end_time and remaining <= 0: | ||||||
|  |                 cleanup_playwright_session() | ||||||
|  |                 return make_response('Browser session expired, please reload the Browser Steps interface', 401) | ||||||
|  |  | ||||||
|  |         # Actions - step/apply/etc, do the thing and return state | ||||||
|  |         if request.method == 'POST': | ||||||
|  |             # @todo - should always be an existing session | ||||||
|  |             step_operation = request.form.get('operation') | ||||||
|  |             step_selector = request.form.get('selector') | ||||||
|  |             step_optional_value = request.form.get('optional_value') | ||||||
|  |             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 = None | ||||||
|  |                 step_selector = datastore.data['watching'][uuid].get('url') | ||||||
|  |  | ||||||
|  |             # @todo try.. accept.. nice errors not popups.. | ||||||
|  |             try: | ||||||
|  |  | ||||||
|  |                 this_session = browsersteps_live_ui_o.get(browsersteps_session_id) | ||||||
|  |                 if not this_session: | ||||||
|  |                     print("Browser exited") | ||||||
|  |                     return make_response('Browser session ran out of time :( Please reload this page.', 401) | ||||||
|  |  | ||||||
|  |                 this_session.call_action(action_name=step_operation, | ||||||
|  |                                          selector=step_selector, | ||||||
|  |                                          optional_value=step_optional_value) | ||||||
|  |  | ||||||
|  |             except Exception as e: | ||||||
|  |                 print("Exception when calling step operation", step_operation, str(e)) | ||||||
|  |                 # Try to find something of value to give back to the user | ||||||
|  |                 return make_response(str(e).splitlines()[0], 401) | ||||||
|  |  | ||||||
|  |             # Get visual selector ready/update its data (also use the current filter info from the page?) | ||||||
|  |             # When the last 'apply' button was pressed | ||||||
|  |             # @todo this adds overhead because the xpath selection is happening twice | ||||||
|  |             u = this_session.page.url | ||||||
|  |             if is_last_step and u: | ||||||
|  |                 (screenshot, xpath_data) = this_session.request_visualselector_data() | ||||||
|  |                 datastore.save_screenshot(watch_uuid=uuid, screenshot=screenshot) | ||||||
|  |                 datastore.save_xpath_data(watch_uuid=uuid, data=xpath_data) | ||||||
|  |  | ||||||
|  |         # Setup interface | ||||||
|  |         if request.method == 'GET': | ||||||
|  |  | ||||||
|  |             if not browsersteps_playwright_browser_interface: | ||||||
|  |                 print("Starting connection with playwright") | ||||||
|  |                 logging.debug("browser_steps.py connecting") | ||||||
|  |  | ||||||
|  |                 global browsersteps_playwright_browser_interface_context | ||||||
|  |                 from . import nonContext | ||||||
|  |                 browsersteps_playwright_browser_interface_context = nonContext.c_sync_playwright() | ||||||
|  |                 browsersteps_playwright_browser_interface = browsersteps_playwright_browser_interface_context.start() | ||||||
|  |  | ||||||
|  |                 time.sleep(1) | ||||||
|  |                 # At 20 minutes, some other variable is closing it | ||||||
|  |                 # @todo find out what it is and set it | ||||||
|  |                 seconds_keepalive = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60 | ||||||
|  |  | ||||||
|  |                 # keep it alive for 10 seconds more than we advertise, sometimes it helps to keep it shutting down cleanly | ||||||
|  |                 keepalive = "&timeout={}".format(((seconds_keepalive+3) * 1000)) | ||||||
|  |                 try: | ||||||
|  |                     browsersteps_playwright_browser_interface_browser = browsersteps_playwright_browser_interface.chromium.connect_over_cdp( | ||||||
|  |                         os.getenv('PLAYWRIGHT_DRIVER_URL', '') + keepalive) | ||||||
|  |                 except Exception as e: | ||||||
|  |                     if 'ECONNREFUSED' in str(e): | ||||||
|  |                         return make_response('Unable to start the Playwright session properly, is it running?', 401) | ||||||
|  |  | ||||||
|  |                 browsersteps_playwright_browser_interface_end_time = time.time() + (seconds_keepalive-3) | ||||||
|  |                 print("Starting connection with playwright - done") | ||||||
|  |  | ||||||
|  |             if not browsersteps_live_ui_o.get(browsersteps_session_id): | ||||||
|  |                 # Boot up a new session | ||||||
|  |                 proxy_id = datastore.get_preferred_proxy_for_watch(uuid=uuid) | ||||||
|  |                 proxy = None | ||||||
|  |                 if proxy_id: | ||||||
|  |                     proxy_url = datastore.proxy_list.get(proxy_id).get('url') | ||||||
|  |                     if proxy_url: | ||||||
|  |                         proxy = {'server': proxy_url} | ||||||
|  |                         print("Browser Steps: UUID {} Using proxy {}".format(uuid, proxy_url)) | ||||||
|  |  | ||||||
|  |                 # Begin the new "Playwright Context" that re-uses the playwright interface | ||||||
|  |                 # Each session is a "Playwright Context" as a list, that uses the playwright interface | ||||||
|  |                 browsersteps_live_ui_o[browsersteps_session_id] = browser_steps.browsersteps_live_ui( | ||||||
|  |                     playwright_browser=browsersteps_playwright_browser_interface_browser, | ||||||
|  |                     proxy=proxy) | ||||||
|  |                 this_session = browsersteps_live_ui_o[browsersteps_session_id] | ||||||
|  |  | ||||||
|  |         if not this_session.page: | ||||||
|  |             cleanup_playwright_session() | ||||||
|  |             return make_response('Browser session ran out of time :( Please reload this page.', 401) | ||||||
|  |  | ||||||
|  |         response = None | ||||||
|  |  | ||||||
|  |         if request.method == 'POST': | ||||||
|  |             # Screenshots and other info only needed on requesting a step (POST) | ||||||
|  |             try: | ||||||
|  |                 state = this_session.get_current_state() | ||||||
|  |             except playwright._impl._api_types.Error as e: | ||||||
|  |                 return make_response("Browser session ran out of time :( Please reload this page."+str(e), 401) | ||||||
|  |  | ||||||
|  |             # Use send_file() which is way faster than read/write loop on bytes | ||||||
|  |             import json | ||||||
|  |             from tempfile import mkstemp | ||||||
|  |             from flask import send_file | ||||||
|  |             tmp_fd, tmp_file = mkstemp(text=True, suffix=".json", prefix="changedetectionio-") | ||||||
|  |  | ||||||
|  |             output = json.dumps({'screenshot': "data:image/jpeg;base64,{}".format( | ||||||
|  |                 base64.b64encode(state[0]).decode('ascii')), | ||||||
|  |                 'xpath_data': state[1], | ||||||
|  |                 'session_age_start': this_session.age_start, | ||||||
|  |                 'browser_time_remaining': round(remaining) | ||||||
|  |             }) | ||||||
|  |  | ||||||
|  |             with os.fdopen(tmp_fd, 'w') as f: | ||||||
|  |                 f.write(output) | ||||||
|  |  | ||||||
|  |             response = make_response(send_file(path_or_file=tmp_file, | ||||||
|  |                                                mimetype='application/json; charset=UTF-8', | ||||||
|  |                                                etag=True)) | ||||||
|  |             # No longer needed | ||||||
|  |             os.unlink(tmp_file) | ||||||
|  |  | ||||||
|  |         elif request.method == 'GET': | ||||||
|  |             # Just enough to get the session rolling, it will call for goto-site via POST next | ||||||
|  |             response = make_response({ | ||||||
|  |                 'session_age_start': this_session.age_start, | ||||||
|  |                 'browser_time_remaining': round(remaining) | ||||||
|  |             }) | ||||||
|  |  | ||||||
|  |         return response | ||||||
|  |  | ||||||
|  |     return browser_steps_blueprint | ||||||
|  |  | ||||||
|  |  | ||||||
							
								
								
									
										270
									
								
								changedetectionio/blueprint/browser_steps/browser_steps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,270 @@ | |||||||
|  | #!/usr/bin/python3 | ||||||
|  |  | ||||||
|  | import os | ||||||
|  | import time | ||||||
|  | import re | ||||||
|  | from random import randint | ||||||
|  |  | ||||||
|  | # Two flags, tell the JS which of the "Selector" or "Value" field should be enabled in the front end | ||||||
|  | # 0- off, 1- on | ||||||
|  | browser_step_ui_config = {'Choose one': '0 0', | ||||||
|  |                           #                 'Check checkbox': '1 0', | ||||||
|  |                           #                 'Click button containing text': '0 1', | ||||||
|  |                           #                 'Scroll to bottom': '0 0', | ||||||
|  |                           #                 'Scroll to element': '1 0', | ||||||
|  |                           #                 'Scroll to top': '0 0', | ||||||
|  |                           #                 'Switch to iFrame by index number': '0 1' | ||||||
|  |                           #                 'Uncheck checkbox': '1 0', | ||||||
|  |                           # @todo | ||||||
|  |                           'Check checkbox': '1 0', | ||||||
|  |                           'Click X,Y': '0 1', | ||||||
|  |                           'Click element if exists': '1 0', | ||||||
|  |                           'Click element': '1 0', | ||||||
|  |                           'Click element containing text': '0 1', | ||||||
|  |                           'Enter text in field': '1 1', | ||||||
|  |                           'Execute JS': '0 1', | ||||||
|  | #                          'Extract text and use as filter': '1 0', | ||||||
|  |                           'Goto site': '0 0', | ||||||
|  |                           'Press Enter': '0 0', | ||||||
|  |                           'Select by label': '1 1', | ||||||
|  |                           'Scroll down': '0 0', | ||||||
|  |                           'Uncheck checkbox': '1 0', | ||||||
|  |                           'Wait for seconds': '0 1', | ||||||
|  |                           'Wait for text': '0 1', | ||||||
|  |                           #                          'Press Page Down': '0 0', | ||||||
|  |                           #                          'Press Page Up': '0 0', | ||||||
|  |                           # weird bug, come back to it later | ||||||
|  |                           } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Good reference - https://playwright.dev/python/docs/input | ||||||
|  | #                  https://pythonmana.com/2021/12/202112162236307035.html | ||||||
|  | # | ||||||
|  | # ONLY Works in Playwright because we need the fullscreen screenshot | ||||||
|  | class steppable_browser_interface(): | ||||||
|  |     page = None | ||||||
|  |  | ||||||
|  |     # Convert and perform "Click Button" for example | ||||||
|  |     def call_action(self, action_name, selector=None, optional_value=None): | ||||||
|  |         now = time.time() | ||||||
|  |         call_action_name = re.sub('[^0-9a-zA-Z]+', '_', action_name.lower()) | ||||||
|  |         if call_action_name == 'choose_one': | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         print("> action calling", call_action_name) | ||||||
|  |         # https://playwright.dev/python/docs/selectors#xpath-selectors | ||||||
|  |         if selector.startswith('/') and not selector.startswith('//'): | ||||||
|  |             selector = "xpath=" + selector | ||||||
|  |  | ||||||
|  |         action_handler = getattr(self, "action_" + call_action_name) | ||||||
|  |  | ||||||
|  |         # Support for Jinja2 variables in the value and selector | ||||||
|  |         from jinja2 import Environment | ||||||
|  |         jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||||
|  |  | ||||||
|  |         if selector and ('{%' in selector or '{{' in selector): | ||||||
|  |             selector = str(jinja2_env.from_string(selector).render()) | ||||||
|  |  | ||||||
|  |         if optional_value and ('{%' in optional_value or '{{' in optional_value): | ||||||
|  |             optional_value = str(jinja2_env.from_string(optional_value).render()) | ||||||
|  |  | ||||||
|  |         action_handler(selector, optional_value) | ||||||
|  |         self.page.wait_for_timeout(3 * 1000) | ||||||
|  |         print("Call action done in", time.time() - now) | ||||||
|  |  | ||||||
|  |     def action_goto_url(self, url, optional_value): | ||||||
|  |         # self.page.set_viewport_size({"width": 1280, "height": 5000}) | ||||||
|  |         now = time.time() | ||||||
|  |         response = self.page.goto(url, timeout=0, wait_until='domcontentloaded') | ||||||
|  |         print("Time to goto URL", time.time() - now) | ||||||
|  |  | ||||||
|  |         # Wait_until = commit | ||||||
|  |         # - `'commit'` - consider operation to be finished when network response is received and the document started loading. | ||||||
|  |         # Better to not use any smarts from Playwright and just wait an arbitrary number of seconds | ||||||
|  |         # This seemed to solve nearly all 'TimeoutErrors' | ||||||
|  |         extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) | ||||||
|  |         self.page.wait_for_timeout(extra_wait * 1000) | ||||||
|  |  | ||||||
|  |     def action_click_element_containing_text(self, selector=None, value=''): | ||||||
|  |         if not len(value.strip()): | ||||||
|  |             return | ||||||
|  |         elem = self.page.get_by_text(value) | ||||||
|  |         if elem.count(): | ||||||
|  |             elem.first.click(delay=randint(200, 500), timeout=3000) | ||||||
|  |  | ||||||
|  |     def action_enter_text_in_field(self, selector, value): | ||||||
|  |         if not len(selector.strip()): | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         self.page.fill(selector, value, timeout=10 * 1000) | ||||||
|  |  | ||||||
|  |     def action_execute_js(self, selector, value): | ||||||
|  |         self.page.evaluate(value) | ||||||
|  |  | ||||||
|  |     def action_click_element(self, selector, value): | ||||||
|  |         print("Clicking element") | ||||||
|  |         if not len(selector.strip()): | ||||||
|  |             return | ||||||
|  |         self.page.click(selector, timeout=10 * 1000, delay=randint(200, 500)) | ||||||
|  |  | ||||||
|  |     def action_click_element_if_exists(self, selector, value): | ||||||
|  |         import playwright._impl._api_types as _api_types | ||||||
|  |         print("Clicking element if exists") | ||||||
|  |         if not len(selector.strip()): | ||||||
|  |             return | ||||||
|  |         try: | ||||||
|  |             self.page.click(selector, timeout=10 * 1000, delay=randint(200, 500)) | ||||||
|  |         except _api_types.TimeoutError as e: | ||||||
|  |             return | ||||||
|  |         except _api_types.Error as e: | ||||||
|  |             # Element was there, but page redrew and now its long long gone | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |     def action_click_x_y(self, selector, value): | ||||||
|  |         x, y = value.strip().split(',') | ||||||
|  |         x = int(float(x.strip())) | ||||||
|  |         y = int(float(y.strip())) | ||||||
|  |         self.page.mouse.click(x=x, y=y, delay=randint(200, 500)) | ||||||
|  |  | ||||||
|  |     def action_scroll_down(self, selector, value): | ||||||
|  |         # Some sites this doesnt work on for some reason | ||||||
|  |         self.page.mouse.wheel(0, 600) | ||||||
|  |         self.page.wait_for_timeout(1000) | ||||||
|  |  | ||||||
|  |     def action_wait_for_seconds(self, selector, value): | ||||||
|  |         self.page.wait_for_timeout(int(value) * 1000) | ||||||
|  |  | ||||||
|  |     # @todo - in the future make some popout interface to capture what needs to be set | ||||||
|  |     # https://playwright.dev/python/docs/api/class-keyboard | ||||||
|  |     def action_press_enter(self, selector, value): | ||||||
|  |         self.page.keyboard.press("Enter", delay=randint(200, 500)) | ||||||
|  |  | ||||||
|  |     def action_press_page_up(self, selector, value): | ||||||
|  |         self.page.keyboard.press("PageUp", delay=randint(200, 500)) | ||||||
|  |  | ||||||
|  |     def action_press_page_down(self, selector, value): | ||||||
|  |         self.page.keyboard.press("PageDown", delay=randint(200, 500)) | ||||||
|  |  | ||||||
|  |     def action_check_checkbox(self, selector, value): | ||||||
|  |         self.page.locator(selector).check(timeout=1000) | ||||||
|  |  | ||||||
|  |     def action_uncheck_checkbox(self, selector, value): | ||||||
|  |         self.page.locator(selector, timeout=1000).uncheck(timeout=1000) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Responsible for maintaining a live 'context' with browserless | ||||||
|  | # @todo - how long do contexts live for anyway? | ||||||
|  | class browsersteps_live_ui(steppable_browser_interface): | ||||||
|  |     context = None | ||||||
|  |     page = None | ||||||
|  |     render_extra_delay = 1 | ||||||
|  |     stale = False | ||||||
|  |     # bump and kill this if idle after X sec | ||||||
|  |     age_start = 0 | ||||||
|  |  | ||||||
|  |     # use a special driver, maybe locally etc | ||||||
|  |     command_executor = os.getenv( | ||||||
|  |         "PLAYWRIGHT_BROWSERSTEPS_DRIVER_URL" | ||||||
|  |     ) | ||||||
|  |     # if not.. | ||||||
|  |     if not command_executor: | ||||||
|  |         command_executor = os.getenv( | ||||||
|  |             "PLAYWRIGHT_DRIVER_URL", | ||||||
|  |             'ws://playwright-chrome:3000' | ||||||
|  |         ).strip('"') | ||||||
|  |  | ||||||
|  |     browser_type = os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').strip('"') | ||||||
|  |  | ||||||
|  |     def __init__(self, playwright_browser, proxy=None): | ||||||
|  |         self.age_start = time.time() | ||||||
|  |         self.playwright_browser = playwright_browser | ||||||
|  |         if self.context is None: | ||||||
|  |             self.connect(proxy=proxy) | ||||||
|  |  | ||||||
|  |     # Connect and setup a new context | ||||||
|  |     def connect(self, proxy=None): | ||||||
|  |         # Should only get called once - test that | ||||||
|  |         keep_open = 1000 * 60 * 5 | ||||||
|  |         now = time.time() | ||||||
|  |  | ||||||
|  |         # @todo handle multiple contexts, bind a unique id from the browser on each req? | ||||||
|  |         self.context = self.playwright_browser.new_context( | ||||||
|  |             # @todo | ||||||
|  |             #                user_agent=request_headers['User-Agent'] if request_headers.get('User-Agent') else 'Mozilla/5.0', | ||||||
|  |             #               proxy=self.proxy, | ||||||
|  |             # This is needed to enable JavaScript execution on GitHub and others | ||||||
|  |             bypass_csp=True, | ||||||
|  |             # Should never be needed | ||||||
|  |             accept_downloads=False, | ||||||
|  |             proxy=proxy | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self.page = self.context.new_page() | ||||||
|  |  | ||||||
|  |         # self.page.set_default_navigation_timeout(keep_open) | ||||||
|  |         self.page.set_default_timeout(keep_open) | ||||||
|  |         # @todo probably this doesnt work | ||||||
|  |         self.page.on( | ||||||
|  |             "close", | ||||||
|  |             self.mark_as_closed, | ||||||
|  |         ) | ||||||
|  |         # Listen for all console events and handle errors | ||||||
|  |         self.page.on("console", lambda msg: print(f"Browser steps console - {msg.type}: {msg.text} {msg.args}")) | ||||||
|  |  | ||||||
|  |         print("Time to browser setup", time.time() - now) | ||||||
|  |         self.page.wait_for_timeout(1 * 1000) | ||||||
|  |  | ||||||
|  |     def mark_as_closed(self): | ||||||
|  |         print("Page closed, cleaning up..") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def has_expired(self): | ||||||
|  |         if not self.page: | ||||||
|  |             return True | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     def get_current_state(self): | ||||||
|  |         """Return the screenshot and interactive elements mapping, generally always called after action_()""" | ||||||
|  |         from pkg_resources import resource_string | ||||||
|  |         xpath_element_js = resource_string(__name__, "../../res/xpath_element_scraper.js").decode('utf-8') | ||||||
|  |         now = time.time() | ||||||
|  |         self.page.wait_for_timeout(1 * 1000) | ||||||
|  |  | ||||||
|  |         # The actual screenshot | ||||||
|  |         screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=40) | ||||||
|  |  | ||||||
|  |         self.page.evaluate("var include_filters=''") | ||||||
|  |         # Go find the interactive elements | ||||||
|  |         # @todo in the future, something smarter that can scan for elements with .click/focus etc event handlers? | ||||||
|  |         elements = 'a,button,input,select,textarea,i,th,td,p,li,h1,h2,h3,h4,div,span' | ||||||
|  |         xpath_element_js = xpath_element_js.replace('%ELEMENTS%', elements) | ||||||
|  |         xpath_data = self.page.evaluate("async () => {" + xpath_element_js + "}") | ||||||
|  |         # So the JS will find the smallest one first | ||||||
|  |         xpath_data['size_pos'] = sorted(xpath_data['size_pos'], key=lambda k: k['width'] * k['height'], reverse=True) | ||||||
|  |         print("Time to complete get_current_state of browser", time.time() - now) | ||||||
|  |         # except | ||||||
|  |         # playwright._impl._api_types.Error: Browser closed. | ||||||
|  |         # @todo show some countdown timer? | ||||||
|  |         return (screenshot, xpath_data) | ||||||
|  |  | ||||||
|  |     def request_visualselector_data(self): | ||||||
|  |         """ | ||||||
|  |         Does the same that the playwright operation in content_fetcher does | ||||||
|  |         This is used to just bump the VisualSelector data so it' ready to go if they click on the tab | ||||||
|  |         @todo refactor and remove duplicate code, add include_filters | ||||||
|  |         :param xpath_data: | ||||||
|  |         :param screenshot: | ||||||
|  |         :param current_include_filters: | ||||||
|  |         :return: | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         self.page.evaluate("var include_filters=''") | ||||||
|  |         from pkg_resources import resource_string | ||||||
|  |         # The code that scrapes elements and makes a list of elements/size/position to click on in the VisualSelector | ||||||
|  |         xpath_element_js = resource_string(__name__, "../../res/xpath_element_scraper.js").decode('utf-8') | ||||||
|  |         from changedetectionio.content_fetcher import visualselector_xpath_selectors | ||||||
|  |         xpath_element_js = xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) | ||||||
|  |         xpath_data = self.page.evaluate("async () => {" + xpath_element_js + "}") | ||||||
|  |         screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72))) | ||||||
|  |  | ||||||
|  |         return (screenshot, xpath_data) | ||||||
							
								
								
									
										18
									
								
								changedetectionio/blueprint/browser_steps/nonContext.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | |||||||
|  | from playwright.sync_api import PlaywrightContextManager | ||||||
|  | import asyncio | ||||||
|  |  | ||||||
|  | # So playwright wants to run as a context manager, but we do something horrible and hacky | ||||||
|  | # we are holding the session open for as long as possible, then shutting it down, and opening a new one | ||||||
|  | # So it means we don't get to use PlaywrightContextManager' __enter__ __exit__ | ||||||
|  | # To work around this, make goodbye() act the same as the __exit__() | ||||||
|  | # | ||||||
|  | # But actually I think this is because the context is opened correctly with __enter__() but we timeout the connection | ||||||
|  | # then theres some lock condition where we cant destroy it without it hanging | ||||||
|  |  | ||||||
|  | class c_PlaywrightContextManager(PlaywrightContextManager): | ||||||
|  |  | ||||||
|  |     def goodbye(self) -> None: | ||||||
|  |         self.__exit__() | ||||||
|  |  | ||||||
|  | def c_sync_playwright() -> PlaywrightContextManager: | ||||||
|  |     return c_PlaywrightContextManager() | ||||||
| @@ -2,16 +2,32 @@ | |||||||
|  |  | ||||||
| # Launch as a eventlet.wsgi server instance. | # Launch as a eventlet.wsgi server instance. | ||||||
|  |  | ||||||
| import getopt | from distutils.util import strtobool | ||||||
| import os |  | ||||||
| import sys |  | ||||||
|  |  | ||||||
| import eventlet | import eventlet | ||||||
| import eventlet.wsgi | import eventlet.wsgi | ||||||
|  | import getopt | ||||||
|  | import os | ||||||
|  | import signal | ||||||
|  | import sys | ||||||
|  |  | ||||||
| from . import store, changedetection_app, content_fetcher | from . import store, changedetection_app, content_fetcher | ||||||
| from . import __version__ | from . import __version__ | ||||||
|  |  | ||||||
|  | # Only global so we can access it in the signal handler | ||||||
|  | app = None | ||||||
|  | datastore = None | ||||||
|  |  | ||||||
|  | def sigterm_handler(_signo, _stack_frame): | ||||||
|  |     global app | ||||||
|  |     global datastore | ||||||
|  | #    app.config.exit.set() | ||||||
|  |     print('Shutdown: Got SIGTERM, DB saved to disk') | ||||||
|  |     datastore.sync_to_json() | ||||||
|  | #    raise SystemExit | ||||||
|  |  | ||||||
| def main(): | def main(): | ||||||
|  |     global datastore | ||||||
|  |     global app | ||||||
|     ssl_mode = False |     ssl_mode = False | ||||||
|     host = '' |     host = '' | ||||||
|     port = os.environ.get('PORT') or 5000 |     port = os.environ.get('PORT') or 5000 | ||||||
| @@ -35,11 +51,6 @@ def main(): | |||||||
|     create_datastore_dir = False |     create_datastore_dir = False | ||||||
|  |  | ||||||
|     for opt, arg in opts: |     for opt, arg in opts: | ||||||
|         #        if opt == '--purge': |  | ||||||
|         # Remove history, the actual files you need to delete manually. |  | ||||||
|         #            for uuid, watch in datastore.data['watching'].items(): |  | ||||||
|         #                watch.update({'history': {}, 'last_checked': 0, 'last_changed': 0, 'previous_md5': None}) |  | ||||||
|  |  | ||||||
|         if opt == '-s': |         if opt == '-s': | ||||||
|             ssl_mode = True |             ssl_mode = True | ||||||
|  |  | ||||||
| @@ -72,9 +83,12 @@ def main(): | |||||||
|                 "Or use the -C parameter to create the directory.".format(app_config['datastore_path']), file=sys.stderr) |                 "Or use the -C parameter to create the directory.".format(app_config['datastore_path']), file=sys.stderr) | ||||||
|             sys.exit(2) |             sys.exit(2) | ||||||
|  |  | ||||||
|  |  | ||||||
|     datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], version_tag=__version__) |     datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], version_tag=__version__) | ||||||
|     app = changedetection_app(app_config, datastore) |     app = changedetection_app(app_config, datastore) | ||||||
|  |  | ||||||
|  |     signal.signal(signal.SIGTERM, sigterm_handler) | ||||||
|  |  | ||||||
|     # Go into cleanup mode |     # Go into cleanup mode | ||||||
|     if do_cleanup: |     if do_cleanup: | ||||||
|         datastore.remove_unused_snapshots() |         datastore.remove_unused_snapshots() | ||||||
| @@ -89,6 +103,15 @@ def main(): | |||||||
|                     has_password=datastore.data['settings']['application']['password'] != False |                     has_password=datastore.data['settings']['application']['password'] != False | ||||||
|                     ) |                     ) | ||||||
|  |  | ||||||
|  |     # Monitored websites will not receive a Referer header when a user clicks on an outgoing link. | ||||||
|  |     # @Note: Incompatible with password login (and maybe other features) for now, submit a PR! | ||||||
|  |     @app.after_request | ||||||
|  |     def hide_referrer(response): | ||||||
|  |         if strtobool(os.getenv("HIDE_REFERER", 'false')): | ||||||
|  |             response.headers["Referrer-Policy"] = "no-referrer" | ||||||
|  |  | ||||||
|  |         return response | ||||||
|  |  | ||||||
|     # Proxy sub-directory support |     # Proxy sub-directory support | ||||||
|     # Set environment var USE_X_SETTINGS=1 on this script |     # Set environment var USE_X_SETTINGS=1 on this script | ||||||
|     # And then in your proxy_pass settings |     # And then in your proxy_pass settings | ||||||
| @@ -111,4 +134,3 @@ def main(): | |||||||
|     else: |     else: | ||||||
|         eventlet.wsgi.server(eventlet.listen((host, int(port))), app) |         eventlet.wsgi.server(eventlet.listen((host, int(port))), app) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,186 +1,105 @@ | |||||||
| from abc import ABC, abstractmethod | from abc import abstractmethod | ||||||
| import chardet | import chardet | ||||||
| import json | import json | ||||||
|  | import logging | ||||||
| import os | import os | ||||||
| import requests | import requests | ||||||
| import time |  | ||||||
| import sys | import sys | ||||||
|  | import time | ||||||
|  |  | ||||||
|  | visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4, header, footer, section, article, aside, details, main, nav, section, summary' | ||||||
|  |  | ||||||
|  | class Non200ErrorCodeReceived(Exception): | ||||||
|  |     def __init__(self, status_code, url, screenshot=None, xpath_data=None, page_html=None): | ||||||
|  |         # Set this so we can use it in other parts of the app | ||||||
|  |         self.status_code = status_code | ||||||
|  |         self.url = url | ||||||
|  |         self.screenshot = screenshot | ||||||
|  |         self.xpath_data = xpath_data | ||||||
|  |         self.page_text = None | ||||||
|  |  | ||||||
|  |         if page_html: | ||||||
|  |             from changedetectionio import html_tools | ||||||
|  |             self.page_text = html_tools.html_to_text(page_html) | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JSActionExceptions(Exception): | ||||||
|  |     def __init__(self, status_code, url, screenshot, message=''): | ||||||
|  |         self.status_code = status_code | ||||||
|  |         self.url = url | ||||||
|  |         self.screenshot = screenshot | ||||||
|  |         self.message = message | ||||||
|  |         return | ||||||
|  |  | ||||||
|  | class BrowserStepsStepTimout(Exception): | ||||||
|  |     def __init__(self, step_n): | ||||||
|  |         self.step_n = step_n | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |  | ||||||
| class PageUnloadable(Exception): | class PageUnloadable(Exception): | ||||||
|     def __init__(self, status_code, url): |     def __init__(self, status_code, url, screenshot=False, message=False): | ||||||
|         # Set this so we can use it in other parts of the app |         # Set this so we can use it in other parts of the app | ||||||
|         self.status_code = status_code |         self.status_code = status_code | ||||||
|         self.url = url |         self.url = url | ||||||
|  |         self.screenshot = screenshot | ||||||
|  |         self.message = message | ||||||
|         return |         return | ||||||
|     pass |  | ||||||
|  |  | ||||||
| class EmptyReply(Exception): | class EmptyReply(Exception): | ||||||
|     def __init__(self, status_code, url): |     def __init__(self, status_code, url, screenshot=None): | ||||||
|         # Set this so we can use it in other parts of the app |         # Set this so we can use it in other parts of the app | ||||||
|         self.status_code = status_code |         self.status_code = status_code | ||||||
|         self.url = url |         self.url = url | ||||||
|  |         self.screenshot = screenshot | ||||||
|         return |         return | ||||||
|     pass |  | ||||||
|  |  | ||||||
| class ScreenshotUnavailable(Exception): | class ScreenshotUnavailable(Exception): | ||||||
|     def __init__(self, status_code, url): |     def __init__(self, status_code, url, page_html=None): | ||||||
|         # Set this so we can use it in other parts of the app |         # Set this so we can use it in other parts of the app | ||||||
|         self.status_code = status_code |         self.status_code = status_code | ||||||
|         self.url = url |         self.url = url | ||||||
|  |         if page_html: | ||||||
|  |             from html_tools import html_to_text | ||||||
|  |             self.page_text = html_to_text(page_html) | ||||||
|         return |         return | ||||||
|     pass |  | ||||||
|  |  | ||||||
| class ReplyWithContentButNoText(Exception): | class ReplyWithContentButNoText(Exception): | ||||||
|     def __init__(self, status_code, url): |     def __init__(self, status_code, url, screenshot=None): | ||||||
|         # Set this so we can use it in other parts of the app |         # Set this so we can use it in other parts of the app | ||||||
|         self.status_code = status_code |         self.status_code = status_code | ||||||
|         self.url = url |         self.url = url | ||||||
|  |         self.screenshot = screenshot | ||||||
|         return |         return | ||||||
|     pass |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Fetcher(): | class Fetcher(): | ||||||
|     error = None |     error = None | ||||||
|     status_code = None |     status_code = None | ||||||
|     content = None |     content = None | ||||||
|     headers = None |     headers = None | ||||||
|  |     browser_steps = None | ||||||
|  |     browser_steps_screenshot_path = None | ||||||
|  |  | ||||||
|     fetcher_description = "No description" |     fetcher_description = "No description" | ||||||
|     xpath_element_js = """                |     webdriver_js_execute_code = None | ||||||
|                 // Include the getXpath script directly, easier than fetching |     xpath_element_js = "" | ||||||
|                 !function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self).getXPath=n()}(this,function(){return function(e){var n=e;if(n&&n.id)return'//*[@id="'+n.id+'"]';for(var o=[];n&&Node.ELEMENT_NODE===n.nodeType;){for(var i=0,r=!1,d=n.previousSibling;d;)d.nodeType!==Node.DOCUMENT_TYPE_NODE&&d.nodeName===n.nodeName&&i++,d=d.previousSibling;for(d=n.nextSibling;d;){if(d.nodeName===n.nodeName){r=!0;break}d=d.nextSibling}o.push((n.prefix?n.prefix+":":"")+n.localName+(i||r?"["+(i+1)+"]":"")),n=n.parentNode}return o.length?"/"+o.reverse().join("/"):""}}); |  | ||||||
|  |  | ||||||
|  |  | ||||||
|                 const findUpTag = (el) => { |  | ||||||
|                   let r = el |  | ||||||
|                   chained_css = []; |  | ||||||
|                   depth=0; |  | ||||||
|              |  | ||||||
|                 // Strategy 1: Keep going up until we hit an ID tag, imagine it's like  #list-widget div h4 |  | ||||||
|                   while (r.parentNode) { |  | ||||||
|                     if(depth==5) { |  | ||||||
|                       break; |  | ||||||
|                     } |  | ||||||
|                     if('' !==r.id) { |  | ||||||
|                       chained_css.unshift("#"+r.id); |  | ||||||
|                       final_selector= chained_css.join('>'); |  | ||||||
|                       // Be sure theres only one, some sites have multiples of the same ID tag :-( |  | ||||||
|                       if (window.document.querySelectorAll(final_selector).length ==1 ) { |  | ||||||
|                         return final_selector; |  | ||||||
|                       } |  | ||||||
|                       return null; |  | ||||||
|                     } else { |  | ||||||
|                       chained_css.unshift(r.tagName.toLowerCase()); |  | ||||||
|                     } |  | ||||||
|                     r=r.parentNode; |  | ||||||
|                     depth+=1; |  | ||||||
|                   } |  | ||||||
|                   return null; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|                 // @todo - if it's SVG or IMG, go into image diff mode |  | ||||||
|                 var elements = window.document.querySelectorAll("div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4, header, footer, section, article, aside, details, main, nav, section, summary"); |  | ||||||
|                 var size_pos=[]; |  | ||||||
|                 // after page fetch, inject this JS |  | ||||||
|                 // build a map of all elements and their positions (maybe that only include text?) |  | ||||||
|                 var bbox; |  | ||||||
|                 for (var i = 0; i < elements.length; i++) {    |  | ||||||
|                  bbox = elements[i].getBoundingClientRect(); |  | ||||||
|  |  | ||||||
|                  // forget really small ones |  | ||||||
|                  if (bbox['width'] <20 && bbox['height'] < 20 ) { |  | ||||||
|                    continue; |  | ||||||
|                  } |  | ||||||
|  |  | ||||||
|                  // @todo the getXpath kind of sucks, it doesnt know when there is for example just one ID sometimes |  | ||||||
|                  // it should not traverse when we know we can anchor off just an ID one level up etc.. |  | ||||||
|                  // maybe, get current class or id, keep traversing up looking for only class or id until there is just one match  |  | ||||||
|  |  | ||||||
|                  // 1st primitive - if it has class, try joining it all and select, if theres only one.. well thats us. |  | ||||||
|                  xpath_result=false; |  | ||||||
|                   |  | ||||||
|                  try { |  | ||||||
|                    var d= findUpTag(elements[i]); |  | ||||||
|                    if (d) { |  | ||||||
|                      xpath_result =d; |  | ||||||
|                    }                 |  | ||||||
|                  } catch (e) { |  | ||||||
|                    console.log(e); |  | ||||||
|                  } |  | ||||||
|                   |  | ||||||
|                  // You could swap it and default to getXpath and then try the smarter one |  | ||||||
|                  // default back to the less intelligent one |  | ||||||
|                  if (!xpath_result) { |  | ||||||
|                     try { |  | ||||||
|                        // I've seen on FB and eBay that this doesnt work |  | ||||||
|                        // ReferenceError: getXPath is not defined at eval (eval at evaluate (:152:29), <anonymous>:67:20) at UtilityScript.evaluate (<anonymous>:159:18) at UtilityScript.<anonymous> (<anonymous>:1:44) |  | ||||||
|                        xpath_result = getXPath(elements[i]); |  | ||||||
|                      } catch (e) { |  | ||||||
|                        console.log(e); |  | ||||||
|                        continue; |  | ||||||
|                      }             |  | ||||||
|                  } |  | ||||||
|                   |  | ||||||
|                  if(window.getComputedStyle(elements[i]).visibility === "hidden") { |  | ||||||
|                    continue; |  | ||||||
|                  } |  | ||||||
|  |  | ||||||
|                  size_pos.push({ |  | ||||||
|                    xpath: xpath_result, |  | ||||||
|                    width: Math.round(bbox['width']),  |  | ||||||
|                    height: Math.round(bbox['height']),  |  | ||||||
|                    left: Math.floor(bbox['left']),  |  | ||||||
|                    top: Math.floor(bbox['top']),  |  | ||||||
|                    childCount: elements[i].childElementCount |  | ||||||
|                  });                  |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|                 // inject the current one set in the css_filter, which may be a CSS rule |  | ||||||
|                 // used for displaying the current one in VisualSelector, where its not one we generated. |  | ||||||
|                 if (css_filter.length) { |  | ||||||
|                    q=false;                    |  | ||||||
|                    try { |  | ||||||
|                        // is it xpath? |  | ||||||
|                        if (css_filter.startsWith('/') || css_filter.startsWith('xpath:')) { |  | ||||||
|                          q=document.evaluate(css_filter.replace('xpath:',''), document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; |  | ||||||
|                        } else { |  | ||||||
|                          q=document.querySelector(css_filter); |  | ||||||
|                        }                        |  | ||||||
|                    } catch (e) { |  | ||||||
|                     // Maybe catch DOMException and alert?  |  | ||||||
|                      console.log(e);                        |  | ||||||
|                    } |  | ||||||
|                    bbox=false; |  | ||||||
|                    if(q) { |  | ||||||
|                      bbox = q.getBoundingClientRect(); |  | ||||||
|                    } |  | ||||||
|                                     |  | ||||||
|                    if (bbox && bbox['width'] >0 && bbox['height']>0) {                        |  | ||||||
|                        size_pos.push({ |  | ||||||
|                            xpath: css_filter, |  | ||||||
|                            width: bbox['width'],  |  | ||||||
|                            height: bbox['height'], |  | ||||||
|                            left: bbox['left'], |  | ||||||
|                            top: bbox['top'], |  | ||||||
|                            childCount: q.childElementCount |  | ||||||
|                          }); |  | ||||||
|                      } |  | ||||||
|                 } |  | ||||||
|                 // Window.width required for proper scaling in the frontend |  | ||||||
|                 return {'size_pos':size_pos, 'browser_width': window.innerWidth}; |  | ||||||
|     """ |  | ||||||
|     xpath_data = None |     xpath_data = None | ||||||
|  |  | ||||||
|     # Will be needed in the future by the VisualSelector, always get this where possible. |     # Will be needed in the future by the VisualSelector, always get this where possible. | ||||||
|     screenshot = False |     screenshot = False | ||||||
|     fetcher_description = "No description" |  | ||||||
|     system_http_proxy = os.getenv('HTTP_PROXY') |     system_http_proxy = os.getenv('HTTP_PROXY') | ||||||
|     system_https_proxy = os.getenv('HTTPS_PROXY') |     system_https_proxy = os.getenv('HTTPS_PROXY') | ||||||
|  |  | ||||||
|     # Time ONTOP of the system defined env minimum time |     # Time ONTOP of the system defined env minimum time | ||||||
|     render_extract_delay=0 |     render_extract_delay = 0 | ||||||
|  |  | ||||||
|  |     def __init__(self): | ||||||
|  |         from pkg_resources import resource_string | ||||||
|  |         # The code that scrapes elements and makes a list of elements/size/position to click on in the VisualSelector | ||||||
|  |         self.xpath_element_js = resource_string(__name__, "res/xpath_element_scraper.js").decode('utf-8') | ||||||
|  |  | ||||||
|  |  | ||||||
|     @abstractmethod |     @abstractmethod | ||||||
|     def get_error(self): |     def get_error(self): | ||||||
| @@ -194,7 +113,7 @@ class Fetcher(): | |||||||
|             request_body, |             request_body, | ||||||
|             request_method, |             request_method, | ||||||
|             ignore_status_codes=False, |             ignore_status_codes=False, | ||||||
|             current_css_filter=None): |             current_include_filters=None): | ||||||
|         # Should set self.error, self.status_code and self.content |         # Should set self.error, self.status_code and self.content | ||||||
|         pass |         pass | ||||||
|  |  | ||||||
| @@ -206,11 +125,62 @@ class Fetcher(): | |||||||
|     def get_last_status_code(self): |     def get_last_status_code(self): | ||||||
|         return self.status_code |         return self.status_code | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def screenshot_step(self, step_n): | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     @abstractmethod |     @abstractmethod | ||||||
|     # Return true/false if this checker is ready to run, in the case it needs todo some special config check etc |     # Return true/false if this checker is ready to run, in the case it needs todo some special config check etc | ||||||
|     def is_ready(self): |     def is_ready(self): | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|  |     def iterate_browser_steps(self): | ||||||
|  |         from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface | ||||||
|  |         from playwright._impl._api_types import TimeoutError | ||||||
|  |         from jinja2 import Environment | ||||||
|  |         jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||||
|  |  | ||||||
|  |         step_n = 0 | ||||||
|  |  | ||||||
|  |         if self.browser_steps is not None and len(self.browser_steps): | ||||||
|  |             interface = steppable_browser_interface() | ||||||
|  |             interface.page = self.page | ||||||
|  |  | ||||||
|  |             valid_steps = filter(lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'), self.browser_steps) | ||||||
|  |  | ||||||
|  |             for step in valid_steps: | ||||||
|  |                 step_n += 1 | ||||||
|  |                 print(">> Iterating check - browser Step n {} - {}...".format(step_n, step['operation'])) | ||||||
|  |                 self.screenshot_step("before-"+str(step_n)) | ||||||
|  |                 self.save_step_html("before-"+str(step_n)) | ||||||
|  |                 try: | ||||||
|  |                     optional_value = step['optional_value'] | ||||||
|  |                     selector = step['selector'] | ||||||
|  |                     # Support for jinja2 template in step values, with date module added | ||||||
|  |                     if '{%' in step['optional_value'] or '{{' in step['optional_value']: | ||||||
|  |                         optional_value = str(jinja2_env.from_string(step['optional_value']).render()) | ||||||
|  |                     if '{%' in step['selector'] or '{{' in step['selector']: | ||||||
|  |                         selector = str(jinja2_env.from_string(step['selector']).render()) | ||||||
|  |  | ||||||
|  |                     getattr(interface, "call_action")(action_name=step['operation'], | ||||||
|  |                                                       selector=selector, | ||||||
|  |                                                       optional_value=optional_value) | ||||||
|  |                     self.screenshot_step(step_n) | ||||||
|  |                     self.save_step_html(step_n) | ||||||
|  |                 except TimeoutError: | ||||||
|  |                     # Stop processing here | ||||||
|  |                     raise BrowserStepsStepTimout(step_n=step_n) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     # It's always good to reset these | ||||||
|  |     def delete_browser_steps_screenshots(self): | ||||||
|  |         import glob | ||||||
|  |         if self.browser_steps_screenshot_path is not None: | ||||||
|  |             dest = os.path.join(self.browser_steps_screenshot_path, 'step_*.jpeg') | ||||||
|  |             files = glob.glob(dest) | ||||||
|  |             for f in files: | ||||||
|  |                 os.unlink(f) | ||||||
|  |  | ||||||
| #   Maybe for the future, each fetcher provides its own diff output, could be used for text, image | #   Maybe for the future, each fetcher provides its own diff output, could be used for text, image | ||||||
| #   the current one would return javascript output (as we use JS to generate the diff) | #   the current one would return javascript output (as we use JS to generate the diff) | ||||||
| @@ -229,7 +199,6 @@ def available_fetchers(): | |||||||
|  |  | ||||||
|     return p |     return p | ||||||
|  |  | ||||||
|  |  | ||||||
| class base_html_playwright(Fetcher): | class base_html_playwright(Fetcher): | ||||||
|     fetcher_description = "Playwright {}/Javascript".format( |     fetcher_description = "Playwright {}/Javascript".format( | ||||||
|         os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize() |         os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize() | ||||||
| @@ -247,7 +216,7 @@ class base_html_playwright(Fetcher): | |||||||
|     proxy = None |     proxy = None | ||||||
|  |  | ||||||
|     def __init__(self, proxy_override=None): |     def __init__(self, proxy_override=None): | ||||||
|  |         super().__init__() | ||||||
|         # .strip('"') is going to save someone a lot of time when they accidently wrap the env value |         # .strip('"') is going to save someone a lot of time when they accidently wrap the env value | ||||||
|         self.browser_type = os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').strip('"') |         self.browser_type = os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').strip('"') | ||||||
|         self.command_executor = os.getenv( |         self.command_executor = os.getenv( | ||||||
| @@ -269,6 +238,25 @@ class base_html_playwright(Fetcher): | |||||||
|         if proxy_override: |         if proxy_override: | ||||||
|             self.proxy = {'server': proxy_override} |             self.proxy = {'server': proxy_override} | ||||||
|  |  | ||||||
|  |     def screenshot_step(self, step_n=''): | ||||||
|  |  | ||||||
|  |         # There's a bug where we need to do it twice or it doesnt take the whole page, dont know why. | ||||||
|  |         self.page.screenshot(type='jpeg', clip={'x': 1.0, 'y': 1.0, 'width': 1280, 'height': 1024}) | ||||||
|  |         screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=85) | ||||||
|  |  | ||||||
|  |         if self.browser_steps_screenshot_path is not None: | ||||||
|  |             destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n)) | ||||||
|  |             logging.debug("Saving step screenshot to {}".format(destination)) | ||||||
|  |             with open(destination, 'wb') as f: | ||||||
|  |                 f.write(screenshot) | ||||||
|  |  | ||||||
|  |     def save_step_html(self, step_n): | ||||||
|  |         content = self.page.content() | ||||||
|  |         destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.html'.format(step_n)) | ||||||
|  |         logging.debug("Saving step HTML to {}".format(destination)) | ||||||
|  |         with open(destination, 'w') as f: | ||||||
|  |             f.write(content) | ||||||
|  |  | ||||||
|     def run(self, |     def run(self, | ||||||
|             url, |             url, | ||||||
|             timeout, |             timeout, | ||||||
| @@ -276,18 +264,20 @@ class base_html_playwright(Fetcher): | |||||||
|             request_body, |             request_body, | ||||||
|             request_method, |             request_method, | ||||||
|             ignore_status_codes=False, |             ignore_status_codes=False, | ||||||
|             current_css_filter=None): |             current_include_filters=None): | ||||||
|  |  | ||||||
|         from playwright.sync_api import sync_playwright |         from playwright.sync_api import sync_playwright | ||||||
|         import playwright._impl._api_types |         import playwright._impl._api_types | ||||||
|         from playwright._impl._api_types import Error, TimeoutError |  | ||||||
|  |  | ||||||
|  |         self.delete_browser_steps_screenshots() | ||||||
|  |         response = None | ||||||
|         with sync_playwright() as p: |         with sync_playwright() as p: | ||||||
|             browser_type = getattr(p, self.browser_type) |             browser_type = getattr(p, self.browser_type) | ||||||
|  |  | ||||||
|             # Seemed to cause a connection Exception even tho I can see it connect |             # Seemed to cause a connection Exception even tho I can see it connect | ||||||
|             # self.browser = browser_type.connect(self.command_executor, timeout=timeout*1000) |             # self.browser = browser_type.connect(self.command_executor, timeout=timeout*1000) | ||||||
|             browser = browser_type.connect_over_cdp(self.command_executor, timeout=timeout * 1000) |             # 60,000 connection timeout only | ||||||
|  |             browser = browser_type.connect_over_cdp(self.command_executor, timeout=60000) | ||||||
|  |  | ||||||
|             # Set user agent to prevent Cloudflare from blocking the browser |             # Set user agent to prevent Cloudflare from blocking the browser | ||||||
|             # Use the default one configured in the App.py model that's passed from fetch_site_status.py |             # Use the default one configured in the App.py model that's passed from fetch_site_status.py | ||||||
| @@ -300,55 +290,99 @@ class base_html_playwright(Fetcher): | |||||||
|                 accept_downloads=False |                 accept_downloads=False | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             page = context.new_page() |             self.page = context.new_page() | ||||||
|  |             if len(request_headers): | ||||||
|  |                 context.set_extra_http_headers(request_headers) | ||||||
|  |  | ||||||
|             try: |             try: | ||||||
|                # Bug - never set viewport size BEFORE page.goto |                 self.page.set_default_navigation_timeout(90000) | ||||||
|                 response = page.goto(url, timeout=timeout * 1000, wait_until='commit') |                 self.page.set_default_timeout(90000) | ||||||
|  |  | ||||||
|  |                 # Listen for all console events and handle errors | ||||||
|  |                 self.page.on("console", lambda msg: print(f"Playwright console: Watch URL: {url} {msg.type}: {msg.text} {msg.args}")) | ||||||
|  |  | ||||||
|  |                 # Bug - never set viewport size BEFORE page.goto | ||||||
|  |  | ||||||
|  |  | ||||||
|  |                 # Waits for the next navigation. Using Python context manager | ||||||
|  |                 # prevents a race condition between clicking and waiting for a navigation. | ||||||
|  |                 with self.page.expect_navigation(): | ||||||
|  |                     response = self.page.goto(url, wait_until='load') | ||||||
|                 # Wait_until = commit |                 # Wait_until = commit | ||||||
|                 # - `'commit'` - consider operation to be finished when network response is received and the document started loading. |                 # - `'commit'` - consider operation to be finished when network response is received and the document started loading. | ||||||
|                 # Better to not use any smarts from Playwright and just wait an arbitrary number of seconds |                 # Better to not use any smarts from Playwright and just wait an arbitrary number of seconds | ||||||
|                 # This seemed to solve nearly all 'TimeoutErrors' |                 # This seemed to solve nearly all 'TimeoutErrors' | ||||||
|                 extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay |                 extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay | ||||||
|                 page.wait_for_timeout(extra_wait * 1000) |                 self.page.wait_for_timeout(extra_wait * 1000) | ||||||
|  |  | ||||||
|  |                 if self.webdriver_js_execute_code is not None and len(self.webdriver_js_execute_code): | ||||||
|  |                     self.page.evaluate(self.webdriver_js_execute_code) | ||||||
|  |  | ||||||
|             except playwright._impl._api_types.TimeoutError as e: |             except playwright._impl._api_types.TimeoutError as e: | ||||||
|                 context.close() |                 context.close() | ||||||
|                 browser.close() |                 browser.close() | ||||||
|                 raise EmptyReply(url=url, status_code=None) |                 # This can be ok, we will try to grab what we could retrieve | ||||||
|  |                 pass | ||||||
|             except Exception as e: |             except Exception as e: | ||||||
|  |                 print ("other exception when page.goto") | ||||||
|  |                 print (str(e)) | ||||||
|                 context.close() |                 context.close() | ||||||
|                 browser.close() |                 browser.close() | ||||||
|                 raise PageUnloadable(url=url, status_code=None) |                 raise PageUnloadable(url=url, status_code=None) | ||||||
|  |  | ||||||
|  |  | ||||||
|             if response is None: |             if response is None: | ||||||
|                 context.close() |                 context.close() | ||||||
|                 browser.close() |                 browser.close() | ||||||
|                 raise EmptyReply(url=url, status_code=None) |                 print ("response object was none") | ||||||
|  |  | ||||||
|             if len(page.content().strip()) == 0: |  | ||||||
|                 context.close() |  | ||||||
|                 browser.close() |  | ||||||
|                 raise EmptyReply(url=url, status_code=None) |                 raise EmptyReply(url=url, status_code=None) | ||||||
|  |  | ||||||
|             # Bug 2(?) Set the viewport size AFTER loading the page |             # Bug 2(?) Set the viewport size AFTER loading the page | ||||||
|             page.set_viewport_size({"width": 1280, "height": 1024}) |             self.page.set_viewport_size({"width": 1280, "height": 1024}) | ||||||
|  |  | ||||||
|  |             # Run Browser Steps here | ||||||
|  |             self.iterate_browser_steps() | ||||||
|  |  | ||||||
|  |             extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay | ||||||
|  |             time.sleep(extra_wait) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             self.content = self.page.content() | ||||||
|  |             self.status_code = response.status | ||||||
|  |  | ||||||
|  |             if len(self.page.content().strip()) == 0: | ||||||
|  |                 context.close() | ||||||
|  |                 browser.close() | ||||||
|  |                 print ("Content was empty") | ||||||
|  |                 raise EmptyReply(url=url, status_code=None) | ||||||
|  |  | ||||||
|  |             # Bug 2(?) Set the viewport size AFTER loading the page | ||||||
|  |             self.page.set_viewport_size({"width": 1280, "height": 1024}) | ||||||
|  |  | ||||||
|             self.status_code = response.status |             self.status_code = response.status | ||||||
|             self.content = page.content() |             self.content = self.page.content() | ||||||
|             self.headers = response.all_headers() |             self.headers = response.all_headers() | ||||||
|  |  | ||||||
|             if current_css_filter is not None: |             # So we can find an element on the page where its selector was entered manually (maybe not xPath etc) | ||||||
|                 page.evaluate("var css_filter={}".format(json.dumps(current_css_filter))) |             if current_include_filters is not None: | ||||||
|  |                 self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters))) | ||||||
|             else: |             else: | ||||||
|                 page.evaluate("var css_filter=''") |                 self.page.evaluate("var include_filters=''") | ||||||
|  |  | ||||||
|             self.xpath_data = page.evaluate("async () => {" + self.xpath_element_js + "}") |             self.xpath_data = self.page.evaluate("async () => {" + self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) + "}") | ||||||
|  |  | ||||||
|             # Bug 3 in Playwright screenshot handling |             # Bug 3 in Playwright screenshot handling | ||||||
|             # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it |             # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it | ||||||
|             # JPEG is better here because the screenshots can be very very large |             # JPEG is better here because the screenshots can be very very large | ||||||
|  |  | ||||||
|  |             # Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded | ||||||
|  |             # which will significantly increase the IO size between the server and client, it's recommended to use the lowest | ||||||
|  |             # acceptable screenshot quality here | ||||||
|             try: |             try: | ||||||
|                 page.screenshot(type='jpeg', clip={'x': 1.0, 'y': 1.0, 'width': 1280, 'height': 1024}) |                 # Quality set to 1 because it's not used, just used as a work-around for a bug, no need to change this. | ||||||
|                 self.screenshot = page.screenshot(type='jpeg', full_page=True, quality=92) |                 self.page.screenshot(type='jpeg', clip={'x': 1.0, 'y': 1.0, 'width': 1280, 'height': 1024}, quality=1) | ||||||
|  |                 # The actual screenshot | ||||||
|  |                 self.screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72))) | ||||||
|             except Exception as e: |             except Exception as e: | ||||||
|                 context.close() |                 context.close() | ||||||
|                 browser.close() |                 browser.close() | ||||||
| @@ -357,7 +391,6 @@ class base_html_playwright(Fetcher): | |||||||
|             context.close() |             context.close() | ||||||
|             browser.close() |             browser.close() | ||||||
|  |  | ||||||
|  |  | ||||||
| class base_html_webdriver(Fetcher): | class base_html_webdriver(Fetcher): | ||||||
|     if os.getenv("WEBDRIVER_URL"): |     if os.getenv("WEBDRIVER_URL"): | ||||||
|         fetcher_description = "WebDriver Chrome/Javascript via '{}'".format(os.getenv("WEBDRIVER_URL")) |         fetcher_description = "WebDriver Chrome/Javascript via '{}'".format(os.getenv("WEBDRIVER_URL")) | ||||||
| @@ -374,6 +407,7 @@ class base_html_webdriver(Fetcher): | |||||||
|     proxy = None |     proxy = None | ||||||
|  |  | ||||||
|     def __init__(self, proxy_override=None): |     def __init__(self, proxy_override=None): | ||||||
|  |         super().__init__() | ||||||
|         from selenium.webdriver.common.proxy import Proxy as SeleniumProxy |         from selenium.webdriver.common.proxy import Proxy as SeleniumProxy | ||||||
|  |  | ||||||
|         # .strip('"') is going to save someone a lot of time when they accidently wrap the env value |         # .strip('"') is going to save someone a lot of time when they accidently wrap the env value | ||||||
| @@ -406,7 +440,7 @@ class base_html_webdriver(Fetcher): | |||||||
|             request_body, |             request_body, | ||||||
|             request_method, |             request_method, | ||||||
|             ignore_status_codes=False, |             ignore_status_codes=False, | ||||||
|             current_css_filter=None): |             current_include_filters=None): | ||||||
|  |  | ||||||
|         from selenium import webdriver |         from selenium import webdriver | ||||||
|         from selenium.webdriver.common.desired_capabilities import DesiredCapabilities |         from selenium.webdriver.common.desired_capabilities import DesiredCapabilities | ||||||
| @@ -428,7 +462,11 @@ class base_html_webdriver(Fetcher): | |||||||
|  |  | ||||||
|         self.driver.set_window_size(1280, 1024) |         self.driver.set_window_size(1280, 1024) | ||||||
|         self.driver.implicitly_wait(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5))) |         self.driver.implicitly_wait(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5))) | ||||||
|         self.screenshot = self.driver.get_screenshot_as_png() |  | ||||||
|  |         if self.webdriver_js_execute_code is not None: | ||||||
|  |             self.driver.execute_script(self.webdriver_js_execute_code) | ||||||
|  |             # Selenium doesn't automatically wait for actions as good as Playwright, so wait again | ||||||
|  |             self.driver.implicitly_wait(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5))) | ||||||
|  |  | ||||||
|         # @todo - how to check this? is it possible? |         # @todo - how to check this? is it possible? | ||||||
|         self.status_code = 200 |         self.status_code = 200 | ||||||
| @@ -440,11 +478,12 @@ class base_html_webdriver(Fetcher): | |||||||
|         self.content = self.driver.page_source |         self.content = self.driver.page_source | ||||||
|         self.headers = {} |         self.headers = {} | ||||||
|  |  | ||||||
|  |         self.screenshot = self.driver.get_screenshot_as_png() | ||||||
|  |  | ||||||
|     # Does the connection to the webdriver work? run a test connection. |     # Does the connection to the webdriver work? run a test connection. | ||||||
|     def is_ready(self): |     def is_ready(self): | ||||||
|         from selenium import webdriver |         from selenium import webdriver | ||||||
|         from selenium.webdriver.common.desired_capabilities import DesiredCapabilities |         from selenium.webdriver.common.desired_capabilities import DesiredCapabilities | ||||||
|         from selenium.common.exceptions import WebDriverException |  | ||||||
|  |  | ||||||
|         self.driver = webdriver.Remote( |         self.driver = webdriver.Remote( | ||||||
|             command_executor=self.command_executor, |             command_executor=self.command_executor, | ||||||
| @@ -476,9 +515,14 @@ class html_requests(Fetcher): | |||||||
|             request_body, |             request_body, | ||||||
|             request_method, |             request_method, | ||||||
|             ignore_status_codes=False, |             ignore_status_codes=False, | ||||||
|             current_css_filter=None): |             current_include_filters=None): | ||||||
|  |  | ||||||
|         proxies={} |         # Make requests use a more modern looking user-agent | ||||||
|  |         if not 'User-Agent' in request_headers: | ||||||
|  |             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 |         # Allows override the proxy on a per-request basis | ||||||
|         if self.proxy_override: |         if self.proxy_override: | ||||||
| @@ -506,10 +550,14 @@ class html_requests(Fetcher): | |||||||
|             if encoding: |             if encoding: | ||||||
|                 r.encoding = encoding |                 r.encoding = encoding | ||||||
|  |  | ||||||
|  |         if not r.content or not len(r.content): | ||||||
|  |             raise EmptyReply(url=url, status_code=r.status_code) | ||||||
|  |  | ||||||
|         # @todo test this |         # @todo test this | ||||||
|         # @todo maybe you really want to test zero-byte return pages? |         # @todo maybe you really want to test zero-byte return pages? | ||||||
|         if (not ignore_status_codes and not r) or not r.content or not len(r.content): |         if r.status_code != 200 and not ignore_status_codes: | ||||||
|             raise EmptyReply(url=url, status_code=r.status_code) |             # maybe check with content works? | ||||||
|  |             raise Non200ErrorCodeReceived(url=url, status_code=r.status_code, page_html=r.text) | ||||||
|  |  | ||||||
|         self.status_code = r.status_code |         self.status_code = r.status_code | ||||||
|         self.content = r.text |         self.content = r.text | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import hashlib | import hashlib | ||||||
|  | import logging | ||||||
| import os | import os | ||||||
| import re | import re | ||||||
| import time | import time | ||||||
| @@ -9,52 +10,48 @@ from changedetectionio import content_fetcher, html_tools | |||||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FilterNotFoundInResponse(ValueError): | ||||||
|  |     def __init__(self, msg): | ||||||
|  |         ValueError.__init__(self, msg) | ||||||
|  |  | ||||||
|  |  | ||||||
| # Some common stuff here that can be moved to a base class | # Some common stuff here that can be moved to a base class | ||||||
|  | # (set_proxy_from_list) | ||||||
| class perform_site_check(): | class perform_site_check(): | ||||||
|  |     screenshot = None | ||||||
|  |     xpath_data = None | ||||||
|  |  | ||||||
|     def __init__(self, *args, datastore, **kwargs): |     def __init__(self, *args, datastore, **kwargs): | ||||||
|         super().__init__(*args, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|         self.datastore = datastore |         self.datastore = datastore | ||||||
|  |  | ||||||
|     # If there was a proxy list enabled, figure out what proxy_args/which proxy to use |     # Doesn't look like python supports forward slash auto enclosure in re.findall | ||||||
|     # if watch.proxy use that |     # So convert it to inline flag "foobar(?i)" type configuration | ||||||
|     # fetcher.proxy_override = watch.proxy or main config proxy |     def forward_slash_enclosed_regex_to_options(self, regex): | ||||||
|     # Allows override the proxy on a per-request basis |         res = re.search(r'^/(.*?)/(\w+)$', regex, re.IGNORECASE) | ||||||
|     # ALWAYS use the first one is nothing selected |  | ||||||
|  |  | ||||||
|     def set_proxy_from_list(self, watch): |         if res: | ||||||
|         proxy_args = None |             regex = res.group(1) | ||||||
|         if self.datastore.proxy_list is None: |             regex += '(?{})'.format(res.group(2)) | ||||||
|             return None |  | ||||||
|  |  | ||||||
|         # If its a valid one |  | ||||||
|         if any([watch['proxy'] in p for p in self.datastore.proxy_list]): |  | ||||||
|             proxy_args = watch['proxy'] |  | ||||||
|  |  | ||||||
|         # not valid (including None), try the system one |  | ||||||
|         else: |         else: | ||||||
|             system_proxy = self.datastore.data['settings']['requests']['proxy'] |             regex += '(?{})'.format('i') | ||||||
|             # Is not None and exists |  | ||||||
|             if any([system_proxy in p for p in self.datastore.proxy_list]): |  | ||||||
|                 proxy_args = system_proxy |  | ||||||
|  |  | ||||||
|         # Fallback - Did not resolve anything, use the first available |         return regex | ||||||
|         if proxy_args is None: |  | ||||||
|             proxy_args = self.datastore.proxy_list[0][0] |  | ||||||
|  |  | ||||||
|         return proxy_args |  | ||||||
|  |  | ||||||
|     def run(self, uuid): |     def run(self, uuid): | ||||||
|         timestamp = int(time.time())  # used for storage etc too |         from copy import deepcopy | ||||||
|  |  | ||||||
|         changed_detected = False |         changed_detected = False | ||||||
|         screenshot = False  # as bytes |         screenshot = False  # as bytes | ||||||
|         stripped_text_from_html = "" |         stripped_text_from_html = "" | ||||||
|  |  | ||||||
|         watch = self.datastore.data['watching'][uuid] |         # DeepCopy so we can be sure we don't accidently change anything by reference | ||||||
|  |         watch = deepcopy(self.datastore.data['watching'].get(uuid)) | ||||||
|  |  | ||||||
|  |         if not watch: | ||||||
|  |             return | ||||||
|  |  | ||||||
|         # Protect against file:// access |         # Protect against file:// access | ||||||
|         if re.search(r'^file', watch['url'], re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False): |         if re.search(r'^file', watch.get('url', ''), re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False): | ||||||
|             raise Exception( |             raise Exception( | ||||||
|                 "file:// type access is denied for security reasons." |                 "file:// type access is denied for security reasons." | ||||||
|             ) |             ) | ||||||
| @@ -62,10 +59,10 @@ class perform_site_check(): | |||||||
|         # Unset any existing notification error |         # Unset any existing notification error | ||||||
|         update_obj = {'last_notification_error': False, 'last_error': False} |         update_obj = {'last_notification_error': False, 'last_error': False} | ||||||
|  |  | ||||||
|         extra_headers = self.datastore.get_val(uuid, 'headers') |         extra_headers = watch.get('headers', []) | ||||||
|  |  | ||||||
|         # Tweak the base config with the per-watch ones |         # Tweak the base config with the per-watch ones | ||||||
|         request_headers = self.datastore.data['settings']['headers'].copy() |         request_headers = deepcopy(self.datastore.data['settings']['headers']) | ||||||
|         request_headers.update(extra_headers) |         request_headers.update(extra_headers) | ||||||
|  |  | ||||||
|         # https://github.com/psf/requests/issues/4525 |         # https://github.com/psf/requests/issues/4525 | ||||||
| @@ -74,11 +71,13 @@ class perform_site_check(): | |||||||
|         if 'Accept-Encoding' in request_headers and "br" in request_headers['Accept-Encoding']: |         if 'Accept-Encoding' in request_headers and "br" in request_headers['Accept-Encoding']: | ||||||
|             request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '') |             request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '') | ||||||
|  |  | ||||||
|         timeout = self.datastore.data['settings']['requests']['timeout'] |         timeout = self.datastore.data['settings']['requests'].get('timeout') | ||||||
|         url = self.datastore.get_val(uuid, 'url') |  | ||||||
|         request_body = self.datastore.get_val(uuid, 'body') |         url = watch.link | ||||||
|         request_method = self.datastore.get_val(uuid, 'method') |  | ||||||
|         ignore_status_code = self.datastore.get_val(uuid, 'ignore_status_codes') |         request_body = self.datastore.data['watching'][uuid].get('body') | ||||||
|  |         request_method = self.datastore.data['watching'][uuid].get('method') | ||||||
|  |         ignore_status_codes = self.datastore.data['watching'][uuid].get('ignore_status_codes', False) | ||||||
|  |  | ||||||
|         # source: support |         # source: support | ||||||
|         is_source = False |         is_source = False | ||||||
| @@ -87,27 +86,42 @@ class perform_site_check(): | |||||||
|             is_source = True |             is_source = True | ||||||
|  |  | ||||||
|         # Pluggable content fetcher |         # Pluggable content fetcher | ||||||
|         prefer_backend = watch['fetch_backend'] |         prefer_backend = watch.get('fetch_backend') | ||||||
|         if hasattr(content_fetcher, prefer_backend): |         if hasattr(content_fetcher, prefer_backend): | ||||||
|             klass = getattr(content_fetcher, prefer_backend) |             klass = getattr(content_fetcher, prefer_backend) | ||||||
|         else: |         else: | ||||||
|             # If the klass doesnt exist, just use a default |             # If the klass doesnt exist, just use a default | ||||||
|             klass = getattr(content_fetcher, "html_requests") |             klass = getattr(content_fetcher, "html_requests") | ||||||
|  |  | ||||||
|  |         proxy_id = self.datastore.get_preferred_proxy_for_watch(uuid=uuid) | ||||||
|  |         proxy_url = None | ||||||
|  |         if proxy_id: | ||||||
|  |             proxy_url = self.datastore.proxy_list.get(proxy_id).get('url') | ||||||
|  |             print("UUID {} Using proxy {}".format(uuid, proxy_url)) | ||||||
|  |  | ||||||
|         proxy_args = self.set_proxy_from_list(watch) |         fetcher = klass(proxy_override=proxy_url) | ||||||
|         fetcher = klass(proxy_override=proxy_args) |  | ||||||
|  |  | ||||||
|         # Configurable per-watch or global extra delay before extracting text (for webDriver types) |         # Configurable per-watch or global extra delay before extracting text (for webDriver types) | ||||||
|         system_webdriver_delay = self.datastore.data['settings']['application'].get('webdriver_delay', None) |         system_webdriver_delay = self.datastore.data['settings']['application'].get('webdriver_delay', None) | ||||||
|         if watch['webdriver_delay'] is not None: |         if watch['webdriver_delay'] is not None: | ||||||
|             fetcher.render_extract_delay = watch['webdriver_delay'] |             fetcher.render_extract_delay = watch.get('webdriver_delay') | ||||||
|         elif system_webdriver_delay is not None: |         elif system_webdriver_delay is not None: | ||||||
|             fetcher.render_extract_delay = system_webdriver_delay |             fetcher.render_extract_delay = system_webdriver_delay | ||||||
|  |  | ||||||
|         fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_code, watch['css_filter']) |         # Possible conflict | ||||||
|  |         if prefer_backend == 'html_webdriver': | ||||||
|  |             fetcher.browser_steps = watch.get('browser_steps', None) | ||||||
|  |             fetcher.browser_steps_screenshot_path = os.path.join(self.datastore.datastore_path, uuid) | ||||||
|  |  | ||||||
|  |         if watch.get('webdriver_js_execute_code') is not None and watch.get('webdriver_js_execute_code').strip(): | ||||||
|  |             fetcher.webdriver_js_execute_code = watch.get('webdriver_js_execute_code') | ||||||
|  |  | ||||||
|  |         fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_codes, watch.get('include_filters')) | ||||||
|         fetcher.quit() |         fetcher.quit() | ||||||
|  |  | ||||||
|  |         self.screenshot = fetcher.screenshot | ||||||
|  |         self.xpath_data = fetcher.xpath_data | ||||||
|  |  | ||||||
|         # Fetching complete, now filters |         # Fetching complete, now filters | ||||||
|         # @todo move to class / maybe inside of fetcher abstract base? |         # @todo move to class / maybe inside of fetcher abstract base? | ||||||
|  |  | ||||||
| @@ -126,27 +140,32 @@ class perform_site_check(): | |||||||
|             is_html = False |             is_html = False | ||||||
|             is_json = False |             is_json = False | ||||||
|  |  | ||||||
|         css_filter_rule = watch['css_filter'] |         include_filters_rule = watch.get('include_filters', []) | ||||||
|  |         # include_filters_rule = watch['include_filters'] | ||||||
|         subtractive_selectors = watch.get( |         subtractive_selectors = watch.get( | ||||||
|             "subtractive_selectors", [] |             "subtractive_selectors", [] | ||||||
|         ) + self.datastore.data["settings"]["application"].get( |         ) + self.datastore.data["settings"]["application"].get( | ||||||
|             "global_subtractive_selectors", [] |             "global_subtractive_selectors", [] | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         has_filter_rule = css_filter_rule and len(css_filter_rule.strip()) |         has_filter_rule = include_filters_rule and len("".join(include_filters_rule).strip()) | ||||||
|         has_subtractive_selectors = subtractive_selectors and len(subtractive_selectors[0].strip()) |         has_subtractive_selectors = subtractive_selectors and len(subtractive_selectors[0].strip()) | ||||||
|  |  | ||||||
|         if is_json and not has_filter_rule: |         if is_json and not has_filter_rule: | ||||||
|             css_filter_rule = "json:$" |             include_filters_rule.append("json:$") | ||||||
|             has_filter_rule = True |             has_filter_rule = True | ||||||
|  |  | ||||||
|         if has_filter_rule: |         if has_filter_rule: | ||||||
|             if 'json:' in css_filter_rule: |             json_filter_prefixes = ['json:', 'jq:'] | ||||||
|                 stripped_text_from_html = html_tools.extract_json_as_string(content=fetcher.content, jsonpath_filter=css_filter_rule) |             for filter in include_filters_rule: | ||||||
|                 is_html = False |                 if any(prefix in filter for prefix in json_filter_prefixes): | ||||||
|  |                     stripped_text_from_html += html_tools.extract_json_as_string(content=fetcher.content, json_filter=filter) | ||||||
|  |                     is_html = False | ||||||
|  |  | ||||||
|         if is_html or is_source: |         if is_html or is_source: | ||||||
|  |  | ||||||
|             # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text |             # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text | ||||||
|  |             fetcher.content = html_tools.workarounds_for_obfuscations(fetcher.content) | ||||||
|             html_content = fetcher.content |             html_content = fetcher.content | ||||||
|  |  | ||||||
|             # If not JSON,  and if it's not text/plain.. |             # If not JSON,  and if it's not text/plain.. | ||||||
| @@ -156,40 +175,43 @@ class perform_site_check(): | |||||||
|             else: |             else: | ||||||
|                 # Then we assume HTML |                 # Then we assume HTML | ||||||
|                 if has_filter_rule: |                 if has_filter_rule: | ||||||
|                     # For HTML/XML we offer xpath as an option, just start a regular xPath "/.." |                     html_content = "" | ||||||
|                     if css_filter_rule[0] == '/' or css_filter_rule.startswith('xpath:'): |                     for filter_rule in include_filters_rule: | ||||||
|                         html_content = html_tools.xpath_filter(xpath_filter=css_filter_rule.replace('xpath:', ''), |                         # For HTML/XML we offer xpath as an option, just start a regular xPath "/.." | ||||||
|                                                                html_content=fetcher.content) |                         if filter_rule[0] == '/' or filter_rule.startswith('xpath:'): | ||||||
|                     else: |                             html_content += html_tools.xpath_filter(xpath_filter=filter_rule.replace('xpath:', ''), | ||||||
|                         # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text |                                                                     html_content=fetcher.content, | ||||||
|                         html_content = html_tools.css_filter(css_filter=css_filter_rule, html_content=fetcher.content) |                                                                     append_pretty_line_formatting=not is_source) | ||||||
|  |                         else: | ||||||
|  |                             # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text | ||||||
|  |                             html_content += html_tools.include_filters(include_filters=filter_rule, | ||||||
|  |                                                                        html_content=fetcher.content, | ||||||
|  |                                                                        append_pretty_line_formatting=not is_source) | ||||||
|  |  | ||||||
|  |                     if not html_content.strip(): | ||||||
|  |                         raise FilterNotFoundInResponse(include_filters_rule) | ||||||
|  |  | ||||||
|                 if has_subtractive_selectors: |                 if has_subtractive_selectors: | ||||||
|                     html_content = html_tools.element_removal(subtractive_selectors, html_content) |                     html_content = html_tools.element_removal(subtractive_selectors, html_content) | ||||||
|  |  | ||||||
|                 if not is_source: |                 if is_source: | ||||||
|  |                     stripped_text_from_html = html_content | ||||||
|  |                 else: | ||||||
|                     # extract text |                     # extract text | ||||||
|  |                     do_anchor = self.datastore.data["settings"]["application"].get("render_anchor_tag_content", False) | ||||||
|                     stripped_text_from_html = \ |                     stripped_text_from_html = \ | ||||||
|                         html_tools.html_to_text( |                         html_tools.html_to_text( | ||||||
|                             html_content, |                             html_content, | ||||||
|                             render_anchor_tag_content=self.datastore.data["settings"][ |                             render_anchor_tag_content=do_anchor | ||||||
|                                 "application"].get( |  | ||||||
|                                 "render_anchor_tag_content", False) |  | ||||||
|                         ) |                         ) | ||||||
|  |  | ||||||
|                 elif is_source: |  | ||||||
|                     stripped_text_from_html = html_content |  | ||||||
|  |  | ||||||
|             # Re #340 - return the content before the 'ignore text' was applied |  | ||||||
|             text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8') |  | ||||||
|  |  | ||||||
|         # Re #340 - return the content before the 'ignore text' was applied |         # Re #340 - return the content before the 'ignore text' was applied | ||||||
|         text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8') |         text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8') | ||||||
|  |  | ||||||
|         # Treat pages with no renderable text content as a change? No by default |         # Treat pages with no renderable text content as a change? No by default | ||||||
|         empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False) |         empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False) | ||||||
|         if not is_json and not empty_pages_are_a_change and len(stripped_text_from_html.strip()) == 0: |         if not is_json and not empty_pages_are_a_change and len(stripped_text_from_html.strip()) == 0: | ||||||
|             raise content_fetcher.ReplyWithContentButNoText(url=url, status_code=200) |             raise content_fetcher.ReplyWithContentButNoText(url=url, status_code=fetcher.get_last_status_code(), screenshot=screenshot) | ||||||
|  |  | ||||||
|         # We rely on the actual text in the html output.. many sites have random script vars etc, |         # We rely on the actual text in the html output.. many sites have random script vars etc, | ||||||
|         # in the future we'll implement other mechanisms. |         # in the future we'll implement other mechanisms. | ||||||
| @@ -204,39 +226,91 @@ class perform_site_check(): | |||||||
|         else: |         else: | ||||||
|             stripped_text_from_html = stripped_text_from_html.encode('utf8') |             stripped_text_from_html = stripped_text_from_html.encode('utf8') | ||||||
|  |  | ||||||
|  |         # 615 Extract text by regex | ||||||
|  |         extract_text = watch.get('extract_text', []) | ||||||
|  |         if len(extract_text) > 0: | ||||||
|  |             regex_matched_output = [] | ||||||
|  |             for s_re in extract_text: | ||||||
|  |                 # incase they specified something in '/.../x' | ||||||
|  |                 regex = self.forward_slash_enclosed_regex_to_options(s_re) | ||||||
|  |                 result = re.findall(regex.encode('utf-8'), stripped_text_from_html) | ||||||
|  |  | ||||||
|  |                 for l in result: | ||||||
|  |                     if type(l) is tuple: | ||||||
|  |                         # @todo - some formatter option default (between groups) | ||||||
|  |                         regex_matched_output += list(l) + [b'\n'] | ||||||
|  |                     else: | ||||||
|  |                         # @todo - some formatter option default (between each ungrouped result) | ||||||
|  |                         regex_matched_output += [l] + [b'\n'] | ||||||
|  |  | ||||||
|  |             # Now we will only show what the regex matched | ||||||
|  |             stripped_text_from_html = b'' | ||||||
|  |             text_content_before_ignored_filter = b'' | ||||||
|  |             if regex_matched_output: | ||||||
|  |                 # @todo some formatter for presentation? | ||||||
|  |                 stripped_text_from_html = b''.join(regex_matched_output) | ||||||
|  |                 text_content_before_ignored_filter = stripped_text_from_html | ||||||
|  |  | ||||||
|         # Re #133 - if we should strip whitespaces from triggering the change detected comparison |         # Re #133 - if we should strip whitespaces from triggering the change detected comparison | ||||||
|         if self.datastore.data['settings']['application'].get('ignore_whitespace', False): |         if self.datastore.data['settings']['application'].get('ignore_whitespace', False): | ||||||
|             fetched_md5 = hashlib.md5(stripped_text_from_html.translate(None, b'\r\n\t ')).hexdigest() |             fetched_md5 = hashlib.md5(stripped_text_from_html.translate(None, b'\r\n\t ')).hexdigest() | ||||||
|         else: |         else: | ||||||
|             fetched_md5 = hashlib.md5(stripped_text_from_html).hexdigest() |             fetched_md5 = hashlib.md5(stripped_text_from_html).hexdigest() | ||||||
|  |  | ||||||
|         # On the first run of a site, watch['previous_md5'] will be None, set it the current one. |         ############ Blocking rules, after checksum ################# | ||||||
|         if not watch.get('previous_md5'): |         blocked = False | ||||||
|             watch['previous_md5'] = fetched_md5 |  | ||||||
|             update_obj["previous_md5"] = fetched_md5 |  | ||||||
|  |  | ||||||
|         blocked_by_not_found_trigger_text = False |         trigger_text = watch.get('trigger_text', []) | ||||||
|  |         if len(trigger_text): | ||||||
|         if len(watch['trigger_text']): |             # Assume blocked | ||||||
|             # Yeah, lets block first until something matches |             blocked = True | ||||||
|             blocked_by_not_found_trigger_text = True |  | ||||||
|             # Filter and trigger works the same, so reuse it |             # Filter and trigger works the same, so reuse it | ||||||
|  |             # It should return the line numbers that match | ||||||
|             result = html_tools.strip_ignore_text(content=str(stripped_text_from_html), |             result = html_tools.strip_ignore_text(content=str(stripped_text_from_html), | ||||||
|                                                   wordlist=watch['trigger_text'], |                                                   wordlist=trigger_text, | ||||||
|                                                   mode="line numbers") |                                                   mode="line numbers") | ||||||
|             # If it returned any lines that matched.. |             # Unblock if the trigger was found | ||||||
|             if result: |             if result: | ||||||
|                 blocked_by_not_found_trigger_text = False |                 blocked = False | ||||||
|  |  | ||||||
|         if not blocked_by_not_found_trigger_text and watch['previous_md5'] != fetched_md5: |         text_should_not_be_present = watch.get('text_should_not_be_present', []) | ||||||
|  |         if len(text_should_not_be_present): | ||||||
|  |             # If anything matched, then we should block a change from happening | ||||||
|  |             result = html_tools.strip_ignore_text(content=str(stripped_text_from_html), | ||||||
|  |                                                   wordlist=text_should_not_be_present, | ||||||
|  |                                                   mode="line numbers") | ||||||
|  |             if result: | ||||||
|  |                 blocked = True | ||||||
|  |  | ||||||
|  |         # The main thing that all this at the moment comes down to :) | ||||||
|  |         if watch.get('previous_md5') != fetched_md5: | ||||||
|             changed_detected = True |             changed_detected = True | ||||||
|             update_obj["previous_md5"] = fetched_md5 |  | ||||||
|             update_obj["last_changed"] = timestamp |         # Looks like something changed, but did it match all the rules? | ||||||
|  |         if blocked: | ||||||
|  |             changed_detected = False | ||||||
|  |  | ||||||
|         # Extract title as title |         # Extract title as title | ||||||
|         if is_html: |         if is_html: | ||||||
|             if self.datastore.data['settings']['application']['extract_title_as_title'] or watch['extract_title_as_title']: |             if self.datastore.data['settings']['application'].get('extract_title_as_title') or watch['extract_title_as_title']: | ||||||
|                 if not watch['title'] or not len(watch['title']): |                 if not watch['title'] or not len(watch['title']): | ||||||
|                     update_obj['title'] = html_tools.extract_element(find='title', html_content=fetcher.content) |                     update_obj['title'] = html_tools.extract_element(find='title', html_content=fetcher.content) | ||||||
|  |  | ||||||
|         return changed_detected, update_obj, text_content_before_ignored_filter, fetcher.screenshot, fetcher.xpath_data |         if changed_detected: | ||||||
|  |             if watch.get('check_unique_lines', False): | ||||||
|  |                 has_unique_lines = watch.lines_contain_something_unique_compared_to_history(lines=stripped_text_from_html.splitlines()) | ||||||
|  |                 # One or more lines? unsure? | ||||||
|  |                 if not has_unique_lines: | ||||||
|  |                     logging.debug("check_unique_lines: UUID {} didnt have anything new setting change_detected=False".format(uuid)) | ||||||
|  |                     changed_detected = False | ||||||
|  |                 else: | ||||||
|  |                     logging.debug("check_unique_lines: UUID {} had unique content".format(uuid)) | ||||||
|  |  | ||||||
|  |         # Always record the new checksum | ||||||
|  |         update_obj["previous_md5"] = fetched_md5 | ||||||
|  |  | ||||||
|  |         # On the first run of a site, watch['previous_md5'] will be None, set it the current one. | ||||||
|  |         if not watch.get('previous_md5'): | ||||||
|  |             watch['previous_md5'] = fetched_md5 | ||||||
|  |  | ||||||
|  |         return changed_detected, update_obj, text_content_before_ignored_filter | ||||||
|   | |||||||
| @@ -1,11 +1,10 @@ | |||||||
|  | import os | ||||||
| import re | import re | ||||||
|  |  | ||||||
| from wtforms import ( | from wtforms import ( | ||||||
|     BooleanField, |     BooleanField, | ||||||
|     Field, |  | ||||||
|     Form, |     Form, | ||||||
|     IntegerField, |     IntegerField, | ||||||
|     PasswordField, |  | ||||||
|     RadioField, |     RadioField, | ||||||
|     SelectField, |     SelectField, | ||||||
|     StringField, |     StringField, | ||||||
| @@ -13,15 +12,17 @@ from wtforms import ( | |||||||
|     TextAreaField, |     TextAreaField, | ||||||
|     fields, |     fields, | ||||||
|     validators, |     validators, | ||||||
|     widgets, |     widgets | ||||||
| ) | ) | ||||||
|  | from wtforms.fields import FieldList | ||||||
| from wtforms.validators import ValidationError | from wtforms.validators import ValidationError | ||||||
|  |  | ||||||
|  | # default | ||||||
|  | # each select <option data-enabled="enabled-0-0" | ||||||
|  | from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config | ||||||
|  |  | ||||||
| from changedetectionio import content_fetcher | from changedetectionio import content_fetcher | ||||||
| from changedetectionio.notification import ( | from changedetectionio.notification import ( | ||||||
|     default_notification_body, |  | ||||||
|     default_notification_format, |  | ||||||
|     default_notification_title, |  | ||||||
|     valid_notification_formats, |     valid_notification_formats, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -206,9 +207,9 @@ class ValidateTokensList(object): | |||||||
|             if not p.strip('{}') in notification.valid_tokens: |             if not p.strip('{}') in notification.valid_tokens: | ||||||
|                 message = field.gettext('Token \'%s\' is not a valid token.') |                 message = field.gettext('Token \'%s\' is not a valid token.') | ||||||
|                 raise ValidationError(message % (p)) |                 raise ValidationError(message % (p)) | ||||||
|              |  | ||||||
| class validateURL(object): | class validateURL(object): | ||||||
|      |  | ||||||
|     """ |     """ | ||||||
|        Flask wtform validators wont work with basic auth |        Flask wtform validators wont work with basic auth | ||||||
|     """ |     """ | ||||||
| @@ -223,7 +224,7 @@ class validateURL(object): | |||||||
|         except validators.ValidationFailure: |         except validators.ValidationFailure: | ||||||
|             message = field.gettext('\'%s\' is not a valid URL.' % (field.data.strip())) |             message = field.gettext('\'%s\' is not a valid URL.' % (field.data.strip())) | ||||||
|             raise ValidationError(message) |             raise ValidationError(message) | ||||||
|          |  | ||||||
| class ValidateListRegex(object): | class ValidateListRegex(object): | ||||||
|     """ |     """ | ||||||
|     Validates that anything that looks like a regex passes as a regex |     Validates that anything that looks like a regex passes as a regex | ||||||
| @@ -303,22 +304,54 @@ class ValidateCSSJSONXPATHInput(object): | |||||||
|  |  | ||||||
|                 # Re #265 - maybe in the future fetch the page and offer a |                 # Re #265 - maybe in the future fetch the page and offer a | ||||||
|                 # warning/notice that its possible the rule doesnt yet match anything? |                 # warning/notice that its possible the rule doesnt yet match anything? | ||||||
|  |                 if not self.allow_json: | ||||||
|  |                     raise ValidationError("jq not permitted in this field!") | ||||||
|  |  | ||||||
|  |             if 'jq:' in line: | ||||||
|  |                 try: | ||||||
|  |                     import jq | ||||||
|  |                 except ModuleNotFoundError: | ||||||
|  |                     # `jq` requires full compilation in windows and so isn't generally available | ||||||
|  |                     raise ValidationError("jq not support not found") | ||||||
|  |  | ||||||
|  |                 input = line.replace('jq:', '') | ||||||
|  |  | ||||||
|  |                 try: | ||||||
|  |                     jq.compile(input) | ||||||
|  |                 except (ValueError) as e: | ||||||
|  |                     message = field.gettext('\'%s\' is not a valid jq expression. (%s)') | ||||||
|  |                     raise ValidationError(message % (input, str(e))) | ||||||
|  |                 except: | ||||||
|  |                     raise ValidationError("A system-error occurred when validating your jq expression") | ||||||
|  |  | ||||||
| class quickWatchForm(Form): | class quickWatchForm(Form): | ||||||
|     url = fields.URLField('URL', validators=[validateURL()]) |     url = fields.URLField('URL', validators=[validateURL()]) | ||||||
|     tag = StringField('Group tag', [validators.Optional()]) |     tag = StringField('Group tag', [validators.Optional()]) | ||||||
|  |     watch_submit_button = SubmitField('Watch', render_kw={"class": "pure-button pure-button-primary"}) | ||||||
|  |     edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"}) | ||||||
|  |  | ||||||
|  |  | ||||||
| # Common to a single watch and the global settings | # Common to a single watch and the global settings | ||||||
| class commonSettingsForm(Form): | class commonSettingsForm(Form): | ||||||
|  |     notification_urls = StringListField('Notification URL list', validators=[validators.Optional(), ValidateAppRiseServers()]) | ||||||
|     notification_urls = StringListField('Notification URL list', validators=[validators.Optional(), ValidateNotificationBodyAndTitleWhenURLisSet(), ValidateAppRiseServers()]) |     notification_title = StringField('Notification title', validators=[validators.Optional(), ValidateTokensList()]) | ||||||
|     notification_title = StringField('Notification title', default=default_notification_title, validators=[validators.Optional(), ValidateTokensList()]) |     notification_body = TextAreaField('Notification body', validators=[validators.Optional(), ValidateTokensList()]) | ||||||
|     notification_body = TextAreaField('Notification body', default=default_notification_body, validators=[validators.Optional(), ValidateTokensList()]) |     notification_format = SelectField('Notification format', choices=valid_notification_formats.keys()) | ||||||
|     notification_format = SelectField('Notification format', choices=valid_notification_formats.keys(), default=default_notification_format) |  | ||||||
|     fetch_backend = RadioField(u'Fetch method', choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) |     fetch_backend = RadioField(u'Fetch method', choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) | ||||||
|     extract_title_as_title = BooleanField('Extract <title> from document and use as watch title', default=False) |     extract_title_as_title = BooleanField('Extract <title> from document and use as watch title', default=False) | ||||||
|     webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")] ) |     webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, | ||||||
|  |                                                                                                                                     message="Should contain one or more seconds")]) | ||||||
|  |  | ||||||
|  | class SingleBrowserStep(Form): | ||||||
|  |  | ||||||
|  |     operation = SelectField('Operation', [validators.Optional()], choices=browser_step_ui_config.keys()) | ||||||
|  |  | ||||||
|  |     # maybe better to set some <script>var.. | ||||||
|  |     selector = StringField('Selector', [validators.Optional()], render_kw={"placeholder": "CSS or xPath selector"}) | ||||||
|  |     optional_value = StringField('value', [validators.Optional()], render_kw={"placeholder": "Value"}) | ||||||
|  | #   @todo move to JS? ajax fetch new field? | ||||||
|  | #    remove_button = SubmitField('-', render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Remove'}) | ||||||
|  | #    add_button = SubmitField('+', render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Add new step after'}) | ||||||
|  |  | ||||||
| class watchForm(commonSettingsForm): | class watchForm(commonSettingsForm): | ||||||
|  |  | ||||||
| @@ -327,9 +360,12 @@ class watchForm(commonSettingsForm): | |||||||
|  |  | ||||||
|     time_between_check = FormField(TimeBetweenCheckForm) |     time_between_check = FormField(TimeBetweenCheckForm) | ||||||
|  |  | ||||||
|     css_filter = StringField('CSS/JSON/XPATH Filter', [ValidateCSSJSONXPATHInput()], default='') |     include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='') | ||||||
|  |  | ||||||
|     subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)]) |     subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)]) | ||||||
|  |  | ||||||
|  |     extract_text = StringListField('Extract text', [ValidateListRegex()]) | ||||||
|  |  | ||||||
|     title = StringField('Title', default='') |     title = StringField('Title', default='') | ||||||
|  |  | ||||||
|     ignore_text = StringListField('Ignore text', [ValidateListRegex()]) |     ignore_text = StringListField('Ignore text', [ValidateListRegex()]) | ||||||
| @@ -337,10 +373,21 @@ class watchForm(commonSettingsForm): | |||||||
|     body = TextAreaField('Request body', [validators.Optional()]) |     body = TextAreaField('Request body', [validators.Optional()]) | ||||||
|     method = SelectField('Request method', choices=valid_method, default=default_method) |     method = SelectField('Request method', choices=valid_method, default=default_method) | ||||||
|     ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False) |     ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False) | ||||||
|  |     check_unique_lines = BooleanField('Only trigger when new lines appear', default=False) | ||||||
|     trigger_text = StringListField('Trigger/wait for text', [validators.Optional(), ValidateListRegex()]) |     trigger_text = StringListField('Trigger/wait for text', [validators.Optional(), ValidateListRegex()]) | ||||||
|  |     if os.getenv("PLAYWRIGHT_DRIVER_URL"): | ||||||
|  |         browser_steps = FieldList(FormField(SingleBrowserStep), min_entries=10) | ||||||
|  |     text_should_not_be_present = StringListField('Block change-detection if text matches', [validators.Optional(), ValidateListRegex()]) | ||||||
|  |     webdriver_js_execute_code = TextAreaField('Execute JavaScript before change detection', render_kw={"rows": "5"}, validators=[validators.Optional()]) | ||||||
|  |  | ||||||
|     save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"}) |     save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"}) | ||||||
|     save_and_preview_button = SubmitField('Save & Preview', render_kw={"class": "pure-button pure-button-primary"}) |  | ||||||
|     proxy = RadioField('Proxy') |     proxy = RadioField('Proxy') | ||||||
|  |     filter_failure_notification_send = BooleanField( | ||||||
|  |         'Send a notification when the filter can no longer be found on the page', default=False) | ||||||
|  |  | ||||||
|  |     notification_muted = BooleanField('Notifications Muted / Off', default=False) | ||||||
|  |     notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False) | ||||||
|  |  | ||||||
|     def validate(self, **kwargs): |     def validate(self, **kwargs): | ||||||
|         if not super().validate(): |         if not super().validate(): | ||||||
| @@ -353,6 +400,15 @@ class watchForm(commonSettingsForm): | |||||||
|             self.body.errors.append('Body must be empty when Request Method is set to GET') |             self.body.errors.append('Body must be empty when Request Method is set to GET') | ||||||
|             result = False |             result = False | ||||||
|  |  | ||||||
|  |         # Attempt to validate jinja2 templates in the URL | ||||||
|  |         from jinja2 import Environment | ||||||
|  |         # Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/ | ||||||
|  |         jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||||
|  |         try: | ||||||
|  |             ready_url = str(jinja2_env.from_string(self.url.data).render()) | ||||||
|  |         except Exception as e: | ||||||
|  |             self.url.errors.append('Invalid template syntax') | ||||||
|  |             result = False | ||||||
|         return result |         return result | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -360,7 +416,9 @@ class watchForm(commonSettingsForm): | |||||||
| class globalSettingsRequestForm(Form): | class globalSettingsRequestForm(Form): | ||||||
|     time_between_check = FormField(TimeBetweenCheckForm) |     time_between_check = FormField(TimeBetweenCheckForm) | ||||||
|     proxy = RadioField('Proxy') |     proxy = RadioField('Proxy') | ||||||
|  |     jitter_seconds = IntegerField('Random jitter seconds ± check', | ||||||
|  |                                   render_kw={"style": "width: 5em;"}, | ||||||
|  |                                   validators=[validators.NumberRange(min=0, message="Should contain zero or more seconds")]) | ||||||
|  |  | ||||||
| # datastore.data['settings']['application'].. | # datastore.data['settings']['application'].. | ||||||
| class globalSettingsApplicationForm(commonSettingsForm): | class globalSettingsApplicationForm(commonSettingsForm): | ||||||
| @@ -369,7 +427,6 @@ class globalSettingsApplicationForm(commonSettingsForm): | |||||||
|     global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)]) |     global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)]) | ||||||
|     global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()]) |     global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()]) | ||||||
|     ignore_whitespace = BooleanField('Ignore whitespace') |     ignore_whitespace = BooleanField('Ignore whitespace') | ||||||
|     real_browser_save_screenshot = BooleanField('Save last screenshot when using Chrome?') |  | ||||||
|     removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"}) |     removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"}) | ||||||
|     empty_pages_are_a_change =  BooleanField('Treat empty pages as a change?', default=False) |     empty_pages_are_a_change =  BooleanField('Treat empty pages as a change?', default=False) | ||||||
|     render_anchor_tag_content = BooleanField('Render anchor tag content', default=False) |     render_anchor_tag_content = BooleanField('Render anchor tag content', default=False) | ||||||
| @@ -377,6 +434,11 @@ class globalSettingsApplicationForm(commonSettingsForm): | |||||||
|     api_access_token_enabled = BooleanField('API access token security check enabled', default=True, validators=[validators.Optional()]) |     api_access_token_enabled = BooleanField('API access token security check enabled', default=True, validators=[validators.Optional()]) | ||||||
|     password = SaltyPasswordField() |     password = SaltyPasswordField() | ||||||
|  |  | ||||||
|  |     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, | ||||||
|  |                                                                                                      message="Should contain zero or more attempts")]) | ||||||
|  |  | ||||||
|  |  | ||||||
| class globalSettingsForm(Form): | class globalSettingsForm(Form): | ||||||
|     # Define these as FormFields/"sub forms", this way it matches the JSON storage |     # Define these as FormFields/"sub forms", this way it matches the JSON storage | ||||||
|   | |||||||
| @@ -1,26 +1,36 @@ | |||||||
| import json |  | ||||||
| import re |  | ||||||
| from typing import List |  | ||||||
|  |  | ||||||
| from bs4 import BeautifulSoup | from bs4 import BeautifulSoup | ||||||
| from jsonpath_ng.ext import parse |  | ||||||
| import re |  | ||||||
| from inscriptis import get_text | from inscriptis import get_text | ||||||
| from inscriptis.model.config import ParserConfig | from inscriptis.model.config import ParserConfig | ||||||
|  | from jsonpath_ng.ext import parse | ||||||
|  | from typing import List | ||||||
|  | import json | ||||||
|  | import re | ||||||
|  |  | ||||||
|  | # HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis | ||||||
|  | TEXT_FILTER_LIST_LINE_SUFFIX = "<br/>" | ||||||
|  |  | ||||||
| class JSONNotFound(ValueError): | class JSONNotFound(ValueError): | ||||||
|     def __init__(self, msg): |     def __init__(self, msg): | ||||||
|         ValueError.__init__(self, msg) |         ValueError.__init__(self, msg) | ||||||
|  |          | ||||||
| # Given a CSS Rule, and a blob of HTML, return the blob of HTML that matches | # Given a CSS Rule, and a blob of HTML, return the blob of HTML that matches | ||||||
| def css_filter(css_filter, html_content): | def include_filters(include_filters, html_content, append_pretty_line_formatting=False): | ||||||
|     soup = BeautifulSoup(html_content, "html.parser") |     soup = BeautifulSoup(html_content, "html.parser") | ||||||
|     html_block = "" |     html_block = "" | ||||||
|     for item in soup.select(css_filter, separator=""): |     r = soup.select(include_filters, separator="") | ||||||
|         html_block += str(item) |  | ||||||
|  |  | ||||||
|     return html_block + "\n" |     for element in r: | ||||||
|  |         # When there's more than 1 match, then add the suffix to separate each line | ||||||
|  |         # And where the matched result doesn't include something that will cause Inscriptis to add a newline | ||||||
|  |         # (This way each 'match' reliably has a new-line in the diff) | ||||||
|  |         # Divs are converted to 4 whitespaces by inscriptis | ||||||
|  |         if append_pretty_line_formatting and len(html_block) and not element.name in (['br', 'hr', 'div', 'p']): | ||||||
|  |             html_block += TEXT_FILTER_LIST_LINE_SUFFIX | ||||||
|  |  | ||||||
|  |         html_block += str(element) | ||||||
|  |  | ||||||
|  |     return html_block | ||||||
|  |  | ||||||
| def subtractive_css_selector(css_selector, html_content): | def subtractive_css_selector(css_selector, html_content): | ||||||
|     soup = BeautifulSoup(html_content, "html.parser") |     soup = BeautifulSoup(html_content, "html.parser") | ||||||
| @@ -36,14 +46,29 @@ def element_removal(selectors: List[str], html_content): | |||||||
|  |  | ||||||
|  |  | ||||||
| # Return str Utf-8 of matched rules | # Return str Utf-8 of matched rules | ||||||
| def xpath_filter(xpath_filter, html_content): | def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False): | ||||||
|     from lxml import etree, html |     from lxml import etree, html | ||||||
|  |  | ||||||
|     tree = html.fromstring(bytes(html_content, encoding='utf-8')) |     tree = html.fromstring(bytes(html_content, encoding='utf-8')) | ||||||
|     html_block = "" |     html_block = "" | ||||||
|  |  | ||||||
|     for item in tree.xpath(xpath_filter.strip(), namespaces={'re':'http://exslt.org/regular-expressions'}): |     r = tree.xpath(xpath_filter.strip(), namespaces={'re': 'http://exslt.org/regular-expressions'}) | ||||||
|         html_block+= etree.tostring(item, pretty_print=True).decode('utf-8')+"<br/>" |     #@note: //title/text() wont work where <title>CDATA.. | ||||||
|  |  | ||||||
|  |     for element in r: | ||||||
|  |         # When there's more than 1 match, then add the suffix to separate each line | ||||||
|  |         # And where the matched result doesn't include something that will cause Inscriptis to add a newline | ||||||
|  |         # (This way each 'match' reliably has a new-line in the diff) | ||||||
|  |         # Divs are converted to 4 whitespaces by inscriptis | ||||||
|  |         if append_pretty_line_formatting and len(html_block) and (not hasattr( element, 'tag' ) or not element.tag in (['br', 'hr', 'div', 'p'])): | ||||||
|  |             html_block += TEXT_FILTER_LIST_LINE_SUFFIX | ||||||
|  |  | ||||||
|  |         if type(element) == etree._ElementStringResult: | ||||||
|  |             html_block += str(element) | ||||||
|  |         elif type(element) == etree._ElementUnicodeResult: | ||||||
|  |             html_block += str(element) | ||||||
|  |         else: | ||||||
|  |             html_block += etree.tostring(element, pretty_print=True).decode('utf-8') | ||||||
|  |  | ||||||
|     return html_block |     return html_block | ||||||
|  |  | ||||||
| @@ -62,19 +87,35 @@ def extract_element(find='title', html_content=''): | |||||||
|     return element_text |     return element_text | ||||||
|  |  | ||||||
| # | # | ||||||
| def _parse_json(json_data, jsonpath_filter): | def _parse_json(json_data, json_filter): | ||||||
|     s=[] |     if 'json:' in json_filter: | ||||||
|     jsonpath_expression = parse(jsonpath_filter.replace('json:', '')) |         jsonpath_expression = parse(json_filter.replace('json:', '')) | ||||||
|     match = jsonpath_expression.find(json_data) |         match = jsonpath_expression.find(json_data) | ||||||
|  |         return _get_stripped_text_from_json_match(match) | ||||||
|  |  | ||||||
|  |     if 'jq:' in json_filter: | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             import jq | ||||||
|  |         except ModuleNotFoundError: | ||||||
|  |             # `jq` requires full compilation in windows and so isn't generally available | ||||||
|  |             raise Exception("jq not support not found") | ||||||
|  |  | ||||||
|  |         jq_expression = jq.compile(json_filter.replace('jq:', '')) | ||||||
|  |         match = jq_expression.input(json_data).all() | ||||||
|  |  | ||||||
|  |         return _get_stripped_text_from_json_match(match) | ||||||
|  |  | ||||||
|  | def _get_stripped_text_from_json_match(match): | ||||||
|  |     s = [] | ||||||
|     # More than one result, we will return it as a JSON list. |     # More than one result, we will return it as a JSON list. | ||||||
|     if len(match) > 1: |     if len(match) > 1: | ||||||
|         for i in match: |         for i in match: | ||||||
|             s.append(i.value) |             s.append(i.value if hasattr(i, 'value') else i) | ||||||
|  |  | ||||||
|     # Single value, use just the value, as it could be later used in a token in notifications. |     # Single value, use just the value, as it could be later used in a token in notifications. | ||||||
|     if len(match) == 1: |     if len(match) == 1: | ||||||
|         s = match[0].value |         s = match[0].value if hasattr(match[0], 'value') else match[0] | ||||||
|  |  | ||||||
|     # Re #257 - Better handling where it does not exist, in the case the original 's' value was False.. |     # Re #257 - Better handling where it does not exist, in the case the original 's' value was False.. | ||||||
|     if not match: |     if not match: | ||||||
| @@ -86,16 +127,16 @@ def _parse_json(json_data, jsonpath_filter): | |||||||
|  |  | ||||||
|     return stripped_text_from_html |     return stripped_text_from_html | ||||||
|  |  | ||||||
| def extract_json_as_string(content, jsonpath_filter): | def extract_json_as_string(content, json_filter): | ||||||
|  |  | ||||||
|     stripped_text_from_html = False |     stripped_text_from_html = False | ||||||
|  |  | ||||||
|     # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded <script type=ldjson> |     # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded <script type=ldjson> | ||||||
|     try: |     try: | ||||||
|         stripped_text_from_html = _parse_json(json.loads(content), jsonpath_filter) |         stripped_text_from_html = _parse_json(json.loads(content), json_filter) | ||||||
|     except json.JSONDecodeError: |     except json.JSONDecodeError: | ||||||
|  |  | ||||||
|         # Foreach <script json></script> blob.. just return the first that matches jsonpath_filter |         # Foreach <script json></script> blob.. just return the first that matches json_filter | ||||||
|         s = [] |         s = [] | ||||||
|         soup = BeautifulSoup(content, 'html.parser') |         soup = BeautifulSoup(content, 'html.parser') | ||||||
|         bs_result = soup.findAll('script') |         bs_result = soup.findAll('script') | ||||||
| @@ -114,7 +155,7 @@ def extract_json_as_string(content, jsonpath_filter): | |||||||
|                 # Just skip it |                 # Just skip it | ||||||
|                 continue |                 continue | ||||||
|             else: |             else: | ||||||
|                 stripped_text_from_html = _parse_json(json_data, jsonpath_filter) |                 stripped_text_from_html = _parse_json(json_data, json_filter) | ||||||
|                 if stripped_text_from_html: |                 if stripped_text_from_html: | ||||||
|                     break |                     break | ||||||
|  |  | ||||||
| @@ -202,3 +243,17 @@ def html_to_text(html_content: str, render_anchor_tag_content=False) -> str: | |||||||
|  |  | ||||||
|     return text_content |     return text_content | ||||||
|  |  | ||||||
|  | def workarounds_for_obfuscations(content): | ||||||
|  |     """ | ||||||
|  |     Some sites are using sneaky tactics to make prices and other information un-renderable by Inscriptis | ||||||
|  |     This could go into its own Pip package in the future, for faster updates | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     # HomeDepot.com style <span>$<!-- -->90<!-- -->.<!-- -->74</span> | ||||||
|  |     # https://github.com/weblyzard/inscriptis/issues/45 | ||||||
|  |     if not content: | ||||||
|  |         return content | ||||||
|  |  | ||||||
|  |     content = re.sub('<!--\s+-->', '', content) | ||||||
|  |  | ||||||
|  |     return content | ||||||
|   | |||||||
| @@ -103,12 +103,12 @@ class import_distill_io_json(Importer): | |||||||
|                     pass |                     pass | ||||||
|                 except IndexError: |                 except IndexError: | ||||||
|                     pass |                     pass | ||||||
|  |                 extras['include_filters'] = [] | ||||||
|                 try: |                 try: | ||||||
|                     extras['css_filter'] = d_config['selections'][0]['frames'][0]['includes'][0]['expr'] |  | ||||||
|                     if d_config['selections'][0]['frames'][0]['includes'][0]['type'] == 'xpath': |                     if d_config['selections'][0]['frames'][0]['includes'][0]['type'] == 'xpath': | ||||||
|                         extras['css_filter'] = 'xpath:' + extras['css_filter'] |                         extras['include_filters'].append('xpath:' + d_config['selections'][0]['frames'][0]['includes'][0]['expr']) | ||||||
|  |                     else: | ||||||
|  |                         extras['include_filters'].append(d_config['selections'][0]['frames'][0]['includes'][0]['expr']) | ||||||
|                 except KeyError: |                 except KeyError: | ||||||
|                     pass |                     pass | ||||||
|                 except IndexError: |                 except IndexError: | ||||||
|   | |||||||
| @@ -1,29 +1,24 @@ | |||||||
| import collections | from os import getenv | ||||||
| import os |  | ||||||
|  |  | ||||||
| import uuid as uuid_builder |  | ||||||
|  |  | ||||||
| from changedetectionio.notification import ( | from changedetectionio.notification import ( | ||||||
|     default_notification_body, |     default_notification_body, | ||||||
|     default_notification_format, |     default_notification_format, | ||||||
|     default_notification_title, |     default_notification_title, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT = 6 | ||||||
|  |  | ||||||
| class model(dict): | class model(dict): | ||||||
|     base_config = { |     base_config = { | ||||||
|             'note': "Hello! If you change this file manually, please be sure to restart your changedetection.io instance!", |             'note': "Hello! If you change this file manually, please be sure to restart your changedetection.io instance!", | ||||||
|             'watching': {}, |             'watching': {}, | ||||||
|             'settings': { |             'settings': { | ||||||
|                 'headers': { |                 'headers': { | ||||||
|                     'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36', |  | ||||||
|                     'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', |  | ||||||
|                     'Accept-Encoding': 'gzip, deflate',  # No support for brolti in python requests yet. |  | ||||||
|                     'Accept-Language': 'en-GB,en-US;q=0.9,en;' |  | ||||||
|                 }, |                 }, | ||||||
|                 'requests': { |                 'requests': { | ||||||
|                     'timeout': 15,  # Default 15 seconds |                     'timeout': int(getenv("DEFAULT_SETTINGS_REQUESTS_TIMEOUT", "45")),  # Default 45 seconds | ||||||
|                     'time_between_check': {'weeks': None, 'days': None, 'hours': 3, 'minutes': None, 'seconds': None}, |                     'time_between_check': {'weeks': None, 'days': None, 'hours': 3, 'minutes': None, 'seconds': None}, | ||||||
|                     'workers': 10,  # Number of threads, lower is better for slow connections |                     'jitter_seconds': 0, | ||||||
|  |                     'workers': int(getenv("DEFAULT_SETTINGS_REQUESTS_WORKERS", "10")),  # Number of threads, lower is better for slow connections | ||||||
|                     'proxy': None # Preferred proxy connection |                     'proxy': None # Preferred proxy connection | ||||||
|                 }, |                 }, | ||||||
|                 'application': { |                 'application': { | ||||||
| @@ -32,7 +27,9 @@ class model(dict): | |||||||
|                     'base_url' : None, |                     'base_url' : None, | ||||||
|                     'extract_title_as_title': False, |                     'extract_title_as_title': False, | ||||||
|                     'empty_pages_are_a_change': False, |                     'empty_pages_are_a_change': False, | ||||||
|                     'fetch_backend': os.getenv("DEFAULT_FETCH_BACKEND", "html_requests"), |                     'css_dark_mode': False, | ||||||
|  |                     'fetch_backend': getenv("DEFAULT_FETCH_BACKEND", "html_requests"), | ||||||
|  |                     'filter_failure_notification_threshold_attempts': _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT, | ||||||
|                     'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum |                     'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum | ||||||
|                     'global_subtractive_selectors': [], |                     'global_subtractive_selectors': [], | ||||||
|                     'ignore_whitespace': True, |                     'ignore_whitespace': True, | ||||||
| @@ -42,7 +39,6 @@ class model(dict): | |||||||
|                     'notification_title': default_notification_title, |                     'notification_title': default_notification_title, | ||||||
|                     'notification_body': default_notification_body, |                     'notification_body': default_notification_body, | ||||||
|                     'notification_format': default_notification_format, |                     'notification_format': default_notification_format, | ||||||
|                     'real_browser_save_screenshot': True, |  | ||||||
|                     'schema_version' : 0, |                     'schema_version' : 0, | ||||||
|                     'webdriver_delay': None  # Extra delay in seconds before extracting text |                     'webdriver_delay': None  # Extra delay in seconds before extracting text | ||||||
|                 } |                 } | ||||||
|   | |||||||
| @@ -1,55 +1,66 @@ | |||||||
|  | from distutils.util import strtobool | ||||||
|  | import logging | ||||||
| import os | import os | ||||||
| import uuid as uuid_builder | import time | ||||||
|  | import uuid | ||||||
|  |  | ||||||
| minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 60)) | minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 60)) | ||||||
|  | mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7} | ||||||
|  |  | ||||||
| from changedetectionio.notification import ( | from changedetectionio.notification import ( | ||||||
|     default_notification_body, |     default_notification_format_for_watch | ||||||
|     default_notification_format, |  | ||||||
|     default_notification_title, |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class model(dict): | class model(dict): | ||||||
|     __newest_history_key = None |     __newest_history_key = None | ||||||
|     __history_n=0 |     __history_n=0 | ||||||
|  |  | ||||||
|     __base_config = { |     __base_config = { | ||||||
|             'url': None, |             #'history': {},  # Dict of timestamp and output stripped filename (removed) | ||||||
|             'tag': None, |             #'newest_history_key': 0, (removed, taken from history.txt index) | ||||||
|             'last_checked': 0, |  | ||||||
|             'last_changed': 0, |  | ||||||
|             'paused': False, |  | ||||||
|             'last_viewed': 0,  # history key value of the last viewed via the [diff] link |  | ||||||
|             #'newest_history_key': 0, |  | ||||||
|             'title': None, |  | ||||||
|             'previous_md5': False, |  | ||||||
|             'uuid': str(uuid_builder.uuid4()), |  | ||||||
|             'headers': {},  # Extra headers to send |  | ||||||
|             'body': None, |             'body': None, | ||||||
|             'method': 'GET', |             'check_unique_lines': False, # On change-detected, compare against all history if its something new | ||||||
|             #'history': {},  # Dict of timestamp and output stripped filename |             'check_count': 0, | ||||||
|             'ignore_text': [],  # List of text to ignore when calculating the comparison checksum |             'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine. | ||||||
|             # Custom notification content |             'extract_text': [],  # Extract text by regex after filters | ||||||
|             'notification_urls': [],  # List of URLs to add to the notification Queue (Usually AppRise) |  | ||||||
|             'notification_title': default_notification_title, |  | ||||||
|             'notification_body': default_notification_body, |  | ||||||
|             'notification_format': default_notification_format, |  | ||||||
|             'css_filter': "", |  | ||||||
|             'subtractive_selectors': [], |  | ||||||
|             'trigger_text': [],  # List of text or regex to wait for until a change is detected |  | ||||||
|             'fetch_backend': None, |  | ||||||
|             'extract_title_as_title': False, |             'extract_title_as_title': False, | ||||||
|  |             'fetch_backend': None, | ||||||
|  |             'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')), | ||||||
|  |             'headers': {},  # Extra headers to send | ||||||
|  |             'ignore_text': [],  # List of text to ignore when calculating the comparison checksum | ||||||
|  |             'include_filters': [], | ||||||
|  |             'last_checked': 0, | ||||||
|  |             'last_error': False, | ||||||
|  |             'last_viewed': 0,  # history key value of the last viewed via the [diff] link | ||||||
|  |             'method': 'GET', | ||||||
|  |              # Custom notification content | ||||||
|  |             'notification_body': None, | ||||||
|  |             'notification_format': default_notification_format_for_watch, | ||||||
|  |             'notification_muted': False, | ||||||
|  |             'notification_title': None, | ||||||
|  |             'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL | ||||||
|  |             'notification_urls': [],  # List of URLs to add to the notification Queue (Usually AppRise) | ||||||
|  |             'paused': False, | ||||||
|  |             'previous_md5': False, | ||||||
|             'proxy': None, # Preferred proxy connection |             'proxy': None, # Preferred proxy connection | ||||||
|  |             'subtractive_selectors': [], | ||||||
|  |             'tag': None, | ||||||
|  |             'text_should_not_be_present': [], # Text that should not present | ||||||
|             # Re #110, so then if this is set to None, we know to use the default value instead |             # Re #110, so then if this is set to None, we know to use the default value instead | ||||||
|             # Requires setting to None on submit if it's the same as the default |             # 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. |             # 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': {'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': None}, | ||||||
|             'webdriver_delay': None |             'title': None, | ||||||
|  |             'trigger_text': [],  # List of text or regex to wait for until a change is detected | ||||||
|  |             'url': None, | ||||||
|  |             'uuid': str(uuid.uuid4()), | ||||||
|  |             'webdriver_delay': None, | ||||||
|  |             'webdriver_js_execute_code': None, # Run before change-detection | ||||||
|         } |         } | ||||||
|  |     jitter_seconds = 0 | ||||||
|  |  | ||||||
|     def __init__(self, *arg, **kw): |     def __init__(self, *arg, **kw): | ||||||
|         import uuid |  | ||||||
|         self.update(self.__base_config) |         self.update(self.__base_config) | ||||||
|         self.__datastore_path = kw['datastore_path'] |         self.__datastore_path = kw['datastore_path'] | ||||||
|  |  | ||||||
| @@ -61,7 +72,10 @@ class model(dict): | |||||||
|             self.update(kw['default']) |             self.update(kw['default']) | ||||||
|             del kw['default'] |             del kw['default'] | ||||||
|  |  | ||||||
|         # goes at the end so we update the default object with the initialiser |         # Be sure the cached timestamp is ready | ||||||
|  |         bump = self.history | ||||||
|  |  | ||||||
|  |         # Goes at the end so we update the default object with the initialiser | ||||||
|         super(model, self).__init__(*arg, **kw) |         super(model, self).__init__(*arg, **kw) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @@ -71,22 +85,88 @@ class model(dict): | |||||||
|  |  | ||||||
|         return False |         return False | ||||||
|  |  | ||||||
|  |     def ensure_data_dir_exists(self): | ||||||
|  |         if not os.path.isdir(self.watch_data_dir): | ||||||
|  |             print ("> Creating data dir {}".format(self.watch_data_dir)) | ||||||
|  |             os.mkdir(self.watch_data_dir) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def link(self): | ||||||
|  |         url = self.get('url', '') | ||||||
|  |         ready_url = url | ||||||
|  |         if '{%' in url or '{{' in url: | ||||||
|  |             from jinja2 import Environment | ||||||
|  |             # Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/ | ||||||
|  |             jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||||
|  |             try: | ||||||
|  |                 ready_url = str(jinja2_env.from_string(url).render()) | ||||||
|  |             except Exception as e: | ||||||
|  |                 from flask import ( | ||||||
|  |                     flash, Markup, url_for | ||||||
|  |                 ) | ||||||
|  |                 message = Markup('<a href="{}#general">The URL {} is invalid and cannot be used, click to edit</a>'.format( | ||||||
|  |                     url_for('edit_page', uuid=self.get('uuid')), self.get('url', ''))) | ||||||
|  |                 flash(message, 'error') | ||||||
|  |                 return '' | ||||||
|  |  | ||||||
|  |         return ready_url | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def label(self): | ||||||
|  |         # Used for sorting | ||||||
|  |         if self['title']: | ||||||
|  |             return self['title'] | ||||||
|  |         return self['url'] | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def last_changed(self): | ||||||
|  |         # last_changed will be the newest snapshot, but when we have just one snapshot, it should be 0 | ||||||
|  |         if self.__history_n <= 1: | ||||||
|  |             return 0 | ||||||
|  |         if self.__newest_history_key: | ||||||
|  |             return int(self.__newest_history_key) | ||||||
|  |         return 0 | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def history_n(self): |     def history_n(self): | ||||||
|         return self.__history_n |         return self.__history_n | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def history(self): |     def history(self): | ||||||
|  |         """History index is just a text file as a list | ||||||
|  |             {watch-uuid}/history.txt | ||||||
|  |  | ||||||
|  |             contains a list like | ||||||
|  |  | ||||||
|  |             {epoch-time},{filename}\n | ||||||
|  |  | ||||||
|  |             We read in this list as the history information | ||||||
|  |  | ||||||
|  |         """ | ||||||
|         tmp_history = {} |         tmp_history = {} | ||||||
|         import logging |  | ||||||
|         import time |  | ||||||
|  |  | ||||||
|         # Read the history file as a dict |         # Read the history file as a dict | ||||||
|         fname = os.path.join(self.__datastore_path, self.get('uuid'), "history.txt") |         fname = os.path.join(self.watch_data_dir, "history.txt") | ||||||
|         if os.path.isfile(fname): |         if os.path.isfile(fname): | ||||||
|             logging.debug("Disk IO accessed " + str(time.time())) |             logging.debug("Reading history index " + str(time.time())) | ||||||
|             with open(fname, "r") as f: |             with open(fname, "r") as f: | ||||||
|                 tmp_history = dict(i.strip().split(',', 2) for i in f.readlines()) |                 for i in f.readlines(): | ||||||
|  |                     if ',' in i: | ||||||
|  |                         k, v = i.strip().split(',', 2) | ||||||
|  |  | ||||||
|  |                         # The index history could contain a relative path, so we need to make the fullpath | ||||||
|  |                         # so that python can read it | ||||||
|  |                         if not '/' in v and not '\'' in v: | ||||||
|  |                             v = os.path.join(self.watch_data_dir, v) | ||||||
|  |                         else: | ||||||
|  |                             # It's possible that they moved the datadir on older versions | ||||||
|  |                             # So the snapshot exists but is in a different path | ||||||
|  |                             snapshot_fname = v.split('/')[-1] | ||||||
|  |                             proposed_new_path = os.path.join(self.watch_data_dir, snapshot_fname) | ||||||
|  |                             if not os.path.exists(v) and os.path.exists(proposed_new_path): | ||||||
|  |                                 v = proposed_new_path | ||||||
|  |  | ||||||
|  |                         tmp_history[k] = v | ||||||
|  |  | ||||||
|         if len(tmp_history): |         if len(tmp_history): | ||||||
|             self.__newest_history_key = list(tmp_history.keys())[-1] |             self.__newest_history_key = list(tmp_history.keys())[-1] | ||||||
| @@ -97,7 +177,7 @@ class model(dict): | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def has_history(self): |     def has_history(self): | ||||||
|         fname = os.path.join(self.__datastore_path, self.get('uuid'), "history.txt") |         fname = os.path.join(self.watch_data_dir, "history.txt") | ||||||
|         return os.path.isfile(fname) |         return os.path.isfile(fname) | ||||||
|  |  | ||||||
|     # Returns the newest key, but if theres only 1 record, then it's counted as not being new, so return 0. |     # Returns the newest key, but if theres only 1 record, then it's counted as not being new, so return 0. | ||||||
| @@ -113,38 +193,36 @@ class model(dict): | |||||||
|         bump = self.history |         bump = self.history | ||||||
|         return self.__newest_history_key |         return self.__newest_history_key | ||||||
|  |  | ||||||
|  |  | ||||||
|     # Save some text file to the appropriate path and bump the history |     # Save some text file to the appropriate path and bump the history | ||||||
|     # result_obj from fetch_site_status.run() |     # result_obj from fetch_site_status.run() | ||||||
|     def save_history_text(self, contents, timestamp): |     def save_history_text(self, contents, timestamp): | ||||||
|         import uuid |  | ||||||
|         from os import mkdir, path, unlink |  | ||||||
|         import logging |  | ||||||
|  |  | ||||||
|         output_path = "{}/{}".format(self.__datastore_path, self['uuid']) |         self.ensure_data_dir_exists() | ||||||
|  |  | ||||||
|         # Incase the operator deleted it, check and create. |         # Small hack so that we sleep just enough to allow 1 second  between history snapshots | ||||||
|         if not os.path.isdir(output_path): |         # this is because history.txt indexes/keys snapshots by epoch seconds and we dont want dupe keys | ||||||
|             mkdir(output_path) |         if self.__newest_history_key and int(timestamp) == int(self.__newest_history_key): | ||||||
|  |             time.sleep(timestamp - self.__newest_history_key) | ||||||
|  |  | ||||||
|         snapshot_fname = "{}/{}.stripped.txt".format(output_path, uuid.uuid4()) |         snapshot_fname = "{}.txt".format(str(uuid.uuid4())) | ||||||
|         logging.debug("Saving history text {}".format(snapshot_fname)) |  | ||||||
|  |  | ||||||
|         with open(snapshot_fname, 'wb') as f: |         # in /diff/ and /preview/ we are going to assume for now that it's UTF-8 when reading | ||||||
|  |         # most sites are utf-8 and some are even broken utf-8 | ||||||
|  |         with open(os.path.join(self.watch_data_dir, snapshot_fname), 'wb') as f: | ||||||
|             f.write(contents) |             f.write(contents) | ||||||
|             f.close() |             f.close() | ||||||
|  |  | ||||||
|         # Append to index |         # Append to index | ||||||
|         # @todo check last char was \n |         # @todo check last char was \n | ||||||
|         index_fname = "{}/history.txt".format(output_path) |         index_fname = os.path.join(self.watch_data_dir, "history.txt") | ||||||
|         with open(index_fname, 'a') as f: |         with open(index_fname, 'a') as f: | ||||||
|             f.write("{},{}\n".format(timestamp, snapshot_fname)) |             f.write("{},{}\n".format(timestamp, snapshot_fname)) | ||||||
|             f.close() |             f.close() | ||||||
|  |  | ||||||
|         self.__newest_history_key = timestamp |         self.__newest_history_key = timestamp | ||||||
|         self.__history_n+=1 |         self.__history_n += 1 | ||||||
|  |  | ||||||
|         #@todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status |         # @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status | ||||||
|         return snapshot_fname |         return snapshot_fname | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @@ -156,9 +234,87 @@ class model(dict): | |||||||
|  |  | ||||||
|     def threshold_seconds(self): |     def threshold_seconds(self): | ||||||
|         seconds = 0 |         seconds = 0 | ||||||
|         mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7} |  | ||||||
|         for m, n in mtable.items(): |         for m, n in mtable.items(): | ||||||
|             x = self.get('time_between_check', {}).get(m, None) |             x = self.get('time_between_check', {}).get(m, None) | ||||||
|             if x: |             if x: | ||||||
|                 seconds += x * n |                 seconds += x * n | ||||||
|         return seconds |         return seconds | ||||||
|  |  | ||||||
|  |     # Iterate over all history texts and see if something new exists | ||||||
|  |     def lines_contain_something_unique_compared_to_history(self, lines: list): | ||||||
|  |         local_lines = set([l.decode('utf-8').strip().lower() for l in lines]) | ||||||
|  |  | ||||||
|  |         # Compare each lines (set) against each history text file (set) looking for something new.. | ||||||
|  |         existing_history = set({}) | ||||||
|  |         for k, v in self.history.items(): | ||||||
|  |             alist = set([line.decode('utf-8').strip().lower() for line in open(v, 'rb')]) | ||||||
|  |             existing_history = existing_history.union(alist) | ||||||
|  |  | ||||||
|  |         # Check that everything in local_lines(new stuff) already exists in existing_history - it should | ||||||
|  |         # if not, something new happened | ||||||
|  |         return not local_lines.issubset(existing_history) | ||||||
|  |  | ||||||
|  |     def get_screenshot(self): | ||||||
|  |         fname = os.path.join(self.watch_data_dir, "last-screenshot.png") | ||||||
|  |         if os.path.isfile(fname): | ||||||
|  |             return fname | ||||||
|  |  | ||||||
|  |         # False is not an option for AppRise, must be type None | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     def get_screenshot_as_jpeg(self): | ||||||
|  |  | ||||||
|  |         # Created by save_screenshot() | ||||||
|  |         fname = os.path.join(self.watch_data_dir, "last-screenshot.jpg") | ||||||
|  |         if os.path.isfile(fname): | ||||||
|  |             return fname | ||||||
|  |  | ||||||
|  |         # False is not an option for AppRise, must be type None | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     def __get_file_ctime(self, filename): | ||||||
|  |         fname = os.path.join(self.watch_data_dir, filename) | ||||||
|  |         if os.path.isfile(fname): | ||||||
|  |             return int(os.path.getmtime(fname)) | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def error_text_ctime(self): | ||||||
|  |         return self.__get_file_ctime('last-error.txt') | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def snapshot_text_ctime(self): | ||||||
|  |         if self.history_n==0: | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |         timestamp = list(self.history.keys())[-1] | ||||||
|  |         return int(timestamp) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def snapshot_screenshot_ctime(self): | ||||||
|  |         return self.__get_file_ctime('last-screenshot.png') | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def snapshot_error_screenshot_ctime(self): | ||||||
|  |         return self.__get_file_ctime('last-error-screenshot.png') | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def watch_data_dir(self): | ||||||
|  |         # The base dir of the watch data | ||||||
|  |         return os.path.join(self.__datastore_path, self['uuid']) | ||||||
|  |      | ||||||
|  |     def get_error_text(self): | ||||||
|  |         """Return the text saved from a previous request that resulted in a non-200 error""" | ||||||
|  |         fname = os.path.join(self.watch_data_dir, "last-error.txt") | ||||||
|  |         if os.path.isfile(fname): | ||||||
|  |             with open(fname, 'r') as f: | ||||||
|  |                 return f.read() | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     def get_error_snapshot(self): | ||||||
|  |         """Return path to the screenshot that resulted in a non-200 error""" | ||||||
|  |         fname = os.path.join(self.watch_data_dir, "last-error-screenshot.png") | ||||||
|  |         if os.path.isfile(fname): | ||||||
|  |             return fname | ||||||
|  |         return False | ||||||
|   | |||||||
| @@ -14,16 +14,19 @@ valid_tokens = { | |||||||
|     'current_snapshot': '' |     'current_snapshot': '' | ||||||
| } | } | ||||||
|  |  | ||||||
|  | default_notification_format_for_watch = 'System default' | ||||||
|  | default_notification_format = 'Text' | ||||||
|  | default_notification_body = '{watch_url} had a change.\n---\n{diff}\n---\n' | ||||||
|  | default_notification_title = 'ChangeDetection.io Notification - {watch_url}' | ||||||
|  |  | ||||||
| valid_notification_formats = { | valid_notification_formats = { | ||||||
|     'Text': NotifyFormat.TEXT, |     'Text': NotifyFormat.TEXT, | ||||||
|     'Markdown': NotifyFormat.MARKDOWN, |     'Markdown': NotifyFormat.MARKDOWN, | ||||||
|     'HTML': NotifyFormat.HTML, |     'HTML': NotifyFormat.HTML, | ||||||
|  |     # Used only for editing a watch (not for global) | ||||||
|  |     default_notification_format_for_watch: default_notification_format_for_watch | ||||||
| } | } | ||||||
|  |  | ||||||
| default_notification_format = 'Text' |  | ||||||
| default_notification_body = '{watch_url} had a change.\n---\n{diff}\n---\n' |  | ||||||
| default_notification_title = 'ChangeDetection.io Notification - {watch_url}' |  | ||||||
|  |  | ||||||
| def process_notification(n_object, datastore): | def process_notification(n_object, datastore): | ||||||
|  |  | ||||||
|     # Get the notification body from datastore |     # Get the notification body from datastore | ||||||
| @@ -34,7 +37,6 @@ def process_notification(n_object, datastore): | |||||||
|         valid_notification_formats[default_notification_format], |         valid_notification_formats[default_notification_format], | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|     # Insert variables into the notification content |     # Insert variables into the notification content | ||||||
|     notification_parameters = create_notification_parameters(n_object, datastore) |     notification_parameters = create_notification_parameters(n_object, datastore) | ||||||
|  |  | ||||||
| @@ -48,9 +50,10 @@ def process_notification(n_object, datastore): | |||||||
|     # Anything higher than or equal to WARNING (which covers things like Connection errors) |     # Anything higher than or equal to WARNING (which covers things like Connection errors) | ||||||
|     # raise it as an exception |     # raise it as an exception | ||||||
|     apobjs=[] |     apobjs=[] | ||||||
|  |     sent_objs=[] | ||||||
|  |     from .apprise_asset import asset | ||||||
|     for url in n_object['notification_urls']: |     for url in n_object['notification_urls']: | ||||||
|  |         apobj = apprise.Apprise(debug=True, asset=asset) | ||||||
|         apobj = apprise.Apprise(debug=True) |  | ||||||
|         url = url.strip() |         url = url.strip() | ||||||
|         if len(url): |         if len(url): | ||||||
|             print(">> Process Notification: AppRise notifying {}".format(url)) |             print(">> Process Notification: AppRise notifying {}".format(url)) | ||||||
| @@ -63,29 +66,45 @@ def process_notification(n_object, datastore): | |||||||
|  |  | ||||||
|                 # So if no avatar_url is specified, add one so it can be correctly calculated into the total payload |                 # So if no avatar_url is specified, add one so it can be correctly calculated into the total payload | ||||||
|                 k = '?' if not '?' in url else '&' |                 k = '?' if not '?' in url else '&' | ||||||
|                 if not 'avatar_url' in url: |                 if not 'avatar_url' in url and not url.startswith('mail'): | ||||||
|                     url += k + 'avatar_url=https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png' |                     url += k + 'avatar_url=https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png' | ||||||
|  |  | ||||||
|                 if url.startswith('tgram://'): |                 if url.startswith('tgram://'): | ||||||
|  |                     # Telegram only supports a limit subset of HTML, remove the '<br/>' we place in. | ||||||
|  |                     # re https://github.com/dgtlmoon/changedetection.io/issues/555 | ||||||
|  |                     # @todo re-use an existing library we have already imported to strip all non-allowed tags | ||||||
|  |                     n_body = n_body.replace('<br/>', '\n') | ||||||
|  |                     n_body = n_body.replace('</br>', '\n') | ||||||
|                     # real limit is 4096, but minus some for extra metadata |                     # real limit is 4096, but minus some for extra metadata | ||||||
|                     payload_max_size = 3600 |                     payload_max_size = 3600 | ||||||
|                     body_limit = max(0, payload_max_size - len(n_title)) |                     body_limit = max(0, payload_max_size - len(n_title)) | ||||||
|                     n_title = n_title[0:payload_max_size] |                     n_title = n_title[0:payload_max_size] | ||||||
|                     n_body = n_body[0:body_limit] |                     n_body = n_body[0:body_limit] | ||||||
|  |  | ||||||
|                 elif url.startswith('discord://'): |                 elif url.startswith('discord://') or url.startswith('https://discordapp.com/api/webhooks') or url.startswith('https://discord.com/api'): | ||||||
|                     # real limit is 2000, but minus some for extra metadata |                     # real limit is 2000, but minus some for extra metadata | ||||||
|                     payload_max_size = 1700 |                     payload_max_size = 1700 | ||||||
|                     body_limit = max(0, payload_max_size - len(n_title)) |                     body_limit = max(0, payload_max_size - len(n_title)) | ||||||
|                     n_title = n_title[0:payload_max_size] |                     n_title = n_title[0:payload_max_size] | ||||||
|                     n_body = n_body[0:body_limit] |                     n_body = n_body[0:body_limit] | ||||||
|  |  | ||||||
|  |                 elif url.startswith('mailto'): | ||||||
|  |                     # Apprise will default to HTML, so we need to override it | ||||||
|  |                     # So that whats' generated in n_body is in line with what is going to be sent. | ||||||
|  |                     # https://github.com/caronc/apprise/issues/633#issuecomment-1191449321 | ||||||
|  |                     if not 'format=' in url and (n_format == 'text' or n_format == 'markdown'): | ||||||
|  |                         prefix = '?' if not '?' in url else '&' | ||||||
|  |                         url = "{}{}format={}".format(url, prefix, n_format) | ||||||
|  |  | ||||||
|                 apobj.add(url) |                 apobj.add(url) | ||||||
|  |  | ||||||
|                 apobj.notify( |                 apobj.notify( | ||||||
|                     title=n_title, |                     title=n_title, | ||||||
|                     body=n_body, |                     body=n_body, | ||||||
|                     body_format=n_format) |                     body_format=n_format, | ||||||
|  |                     # False is not an option for AppRise, must be type None | ||||||
|  |                     attach=n_object.get('screenshot', None) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|                 apobj.clear() |                 apobj.clear() | ||||||
|  |  | ||||||
| @@ -96,6 +115,15 @@ def process_notification(n_object, datastore): | |||||||
|                 log_value = logs.getvalue() |                 log_value = logs.getvalue() | ||||||
|                 if log_value and 'WARNING' in log_value or 'ERROR' in log_value: |                 if log_value and 'WARNING' in log_value or 'ERROR' in log_value: | ||||||
|                     raise Exception(log_value) |                     raise Exception(log_value) | ||||||
|  |                  | ||||||
|  |                 sent_objs.append({'title': n_title, | ||||||
|  |                                   'body': n_body, | ||||||
|  |                                   'url' : url, | ||||||
|  |                                   'body_format': n_format}) | ||||||
|  |  | ||||||
|  |     # Return what was sent for better logging - after the for loop | ||||||
|  |     return sent_objs | ||||||
|  |  | ||||||
|  |  | ||||||
| # Notification title + body content parameters get created here. | # Notification title + body content parameters get created here. | ||||||
| def create_notification_parameters(n_object, datastore): | def create_notification_parameters(n_object, datastore): | ||||||
|   | |||||||
							
								
								
									
										188
									
								
								changedetectionio/res/xpath_element_scraper.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,188 @@ | |||||||
|  | // @file Scrape the page looking for elements of concern (%ELEMENTS%) | ||||||
|  | // http://matatk.agrip.org.uk/tests/position-and-width/ | ||||||
|  | // https://stackoverflow.com/questions/26813480/when-is-element-getboundingclientrect-guaranteed-to-be-updated-accurate | ||||||
|  | // | ||||||
|  | // Some pages like https://www.londonstockexchange.com/stock/NCCL/ncondezi-energy-limited/analysis | ||||||
|  | // will automatically force a scroll somewhere, so include the position offset | ||||||
|  | // Lets hope the position doesnt change while we iterate the bbox's, but this is better than nothing | ||||||
|  |  | ||||||
|  | var scroll_y=+document.documentElement.scrollTop || document.body.scrollTop | ||||||
|  |  | ||||||
|  | // Include the getXpath script directly, easier than fetching | ||||||
|  | function getxpath(e) { | ||||||
|  |         var n = e; | ||||||
|  |         if (n && n.id) return '//*[@id="' + n.id + '"]'; | ||||||
|  |         for (var o = []; n && Node.ELEMENT_NODE === n.nodeType;) { | ||||||
|  |             for (var i = 0, r = !1, d = n.previousSibling; d;) d.nodeType !== Node.DOCUMENT_TYPE_NODE && d.nodeName === n.nodeName && i++, d = d.previousSibling; | ||||||
|  |             for (d = n.nextSibling; d;) { | ||||||
|  |                 if (d.nodeName === n.nodeName) { | ||||||
|  |                     r = !0; | ||||||
|  |                     break | ||||||
|  |                 } | ||||||
|  |                 d = d.nextSibling | ||||||
|  |             } | ||||||
|  |             o.push((n.prefix ? n.prefix + ":" : "") + n.localName + (i || r ? "[" + (i + 1) + "]" : "")), n = n.parentNode | ||||||
|  |         } | ||||||
|  |         return o.length ? "/" + o.reverse().join("/") : "" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | const findUpTag = (el) => { | ||||||
|  |     let r = el | ||||||
|  |     chained_css = []; | ||||||
|  |     depth = 0; | ||||||
|  |  | ||||||
|  |     //  Strategy 1: If it's an input, with name, and there's only one, prefer that | ||||||
|  |     if (el.name !== undefined && el.name.length) { | ||||||
|  |         var proposed = el.tagName + "[name=" + el.name + "]"; | ||||||
|  |         var proposed_element = window.document.querySelectorAll(proposed); | ||||||
|  |         if(proposed_element.length) { | ||||||
|  |             if (proposed_element.length === 1) { | ||||||
|  |                 return proposed; | ||||||
|  |             } else { | ||||||
|  |                 // Some sites change ID but name= stays the same, we can hit it if we know the index | ||||||
|  |                 // Find all the elements that match and work out the input[n] | ||||||
|  |                 var n=Array.from(proposed_element).indexOf(el); | ||||||
|  |                 // Return a Playwright selector for nthinput[name=zipcode] | ||||||
|  |                 return proposed+" >> nth="+n; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Strategy 2: Keep going up until we hit an ID tag, imagine it's like  #list-widget div h4 | ||||||
|  |     while (r.parentNode) { | ||||||
|  |         if (depth == 5) { | ||||||
|  |             break; | ||||||
|  |         } | ||||||
|  |         if ('' !== r.id) { | ||||||
|  |             chained_css.unshift("#" + CSS.escape(r.id)); | ||||||
|  |             final_selector = chained_css.join(' > '); | ||||||
|  |             // Be sure theres only one, some sites have multiples of the same ID tag :-( | ||||||
|  |             if (window.document.querySelectorAll(final_selector).length == 1) { | ||||||
|  |                 return final_selector; | ||||||
|  |             } | ||||||
|  |             return null; | ||||||
|  |         } else { | ||||||
|  |             chained_css.unshift(r.tagName.toLowerCase()); | ||||||
|  |         } | ||||||
|  |         r = r.parentNode; | ||||||
|  |         depth += 1; | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | // @todo - if it's SVG or IMG, go into image diff mode | ||||||
|  | // %ELEMENTS% replaced at injection time because different interfaces use it with different settings | ||||||
|  | var elements = window.document.querySelectorAll("%ELEMENTS%"); | ||||||
|  | var size_pos = []; | ||||||
|  | // after page fetch, inject this JS | ||||||
|  | // build a map of all elements and their positions (maybe that only include text?) | ||||||
|  | var bbox; | ||||||
|  | for (var i = 0; i < elements.length; i++) { | ||||||
|  |     bbox = elements[i].getBoundingClientRect(); | ||||||
|  |  | ||||||
|  |     // Forget really small ones | ||||||
|  |     if (bbox['width'] < 10 && bbox['height'] < 10) { | ||||||
|  |         continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Don't include elements that are offset from canvas | ||||||
|  |     if (bbox['top']+scroll_y < 0 || bbox['left'] < 0) { | ||||||
|  |         continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // @todo the getXpath kind of sucks, it doesnt know when there is for example just one ID sometimes | ||||||
|  |     // it should not traverse when we know we can anchor off just an ID one level up etc.. | ||||||
|  |     // maybe, get current class or id, keep traversing up looking for only class or id until there is just one match | ||||||
|  |  | ||||||
|  |     // 1st primitive - if it has class, try joining it all and select, if theres only one.. well thats us. | ||||||
|  |     xpath_result = false; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |         var d = findUpTag(elements[i]); | ||||||
|  |         if (d) { | ||||||
|  |             xpath_result = d; | ||||||
|  |         } | ||||||
|  |     } catch (e) { | ||||||
|  |         console.log(e); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // You could swap it and default to getXpath and then try the smarter one | ||||||
|  |     // default back to the less intelligent one | ||||||
|  |     if (!xpath_result) { | ||||||
|  |         try { | ||||||
|  |             // I've seen on FB and eBay that this doesnt work | ||||||
|  |             // ReferenceError: getXPath is not defined at eval (eval at evaluate (:152:29), <anonymous>:67:20) at UtilityScript.evaluate (<anonymous>:159:18) at UtilityScript.<anonymous> (<anonymous>:1:44) | ||||||
|  |             xpath_result = getxpath(elements[i]); | ||||||
|  |         } catch (e) { | ||||||
|  |             console.log(e); | ||||||
|  |             continue; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (window.getComputedStyle(elements[i]).visibility === "hidden") { | ||||||
|  |         continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // @todo Possible to ONLY list where it's clickable to save JSON xfer size | ||||||
|  |     size_pos.push({ | ||||||
|  |         xpath: xpath_result, | ||||||
|  |         width: Math.round(bbox['width']), | ||||||
|  |         height: Math.round(bbox['height']), | ||||||
|  |         left: Math.floor(bbox['left']), | ||||||
|  |         top: Math.floor(bbox['top'])+scroll_y, | ||||||
|  |         tagName: (elements[i].tagName) ? elements[i].tagName.toLowerCase() : '', | ||||||
|  |         tagtype: (elements[i].tagName == 'INPUT' && elements[i].type) ? elements[i].type.toLowerCase() : '', | ||||||
|  |         isClickable: (elements[i].onclick) || window.getComputedStyle(elements[i]).cursor == "pointer" | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | // Inject the current one set in the include_filters, which may be a CSS rule | ||||||
|  | // used for displaying the current one in VisualSelector, where its not one we generated. | ||||||
|  | if (include_filters.length) { | ||||||
|  |     // Foreach filter, go and find it on the page and add it to the results so we can visualise it again | ||||||
|  |     for (const f of include_filters) { | ||||||
|  |         bbox = false; | ||||||
|  |         q = false; | ||||||
|  |  | ||||||
|  |         if (!f.length) { | ||||||
|  |             console.log("xpath_element_scraper: Empty filter, skipping"); | ||||||
|  |             continue; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             // is it xpath? | ||||||
|  |             if (f.startsWith('/') || f.startsWith('xpath:')) { | ||||||
|  |                 q = document.evaluate(f.replace('xpath:', ''), document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; | ||||||
|  |             } else { | ||||||
|  |                 q = document.querySelector(f); | ||||||
|  |             } | ||||||
|  |         } catch (e) { | ||||||
|  |             // Maybe catch DOMException and alert? | ||||||
|  |             console.log("xpath_element_scraper: Exception selecting element from filter "+f); | ||||||
|  |             console.log(e); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (q) { | ||||||
|  |             bbox = q.getBoundingClientRect(); | ||||||
|  |             console.log("xpath_element_scraper: Got filter element, scroll from top was "+scroll_y) | ||||||
|  |         } else { | ||||||
|  |             console.log("xpath_element_scraper: filter element "+f+" was not found"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (bbox && bbox['width'] > 0 && bbox['height'] > 0) { | ||||||
|  |             size_pos.push({ | ||||||
|  |                 xpath: f, | ||||||
|  |                 width: parseInt(bbox['width']), | ||||||
|  |                 height: parseInt(bbox['height']), | ||||||
|  |                 left: parseInt(bbox['left']), | ||||||
|  |                 top: parseInt(bbox['top'])+scroll_y | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Window.width required for proper scaling in the frontend | ||||||
|  | return {'size_pos': size_pos, 'browser_width': window.innerWidth}; | ||||||
| @@ -9,6 +9,8 @@ | |||||||
| # exit when any command fails | # exit when any command fails | ||||||
| set -e | set -e | ||||||
|  |  | ||||||
|  | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) | ||||||
|  |  | ||||||
| find tests/test_*py -type f|while read test_name | find tests/test_*py -type f|while read test_name | ||||||
| do | do | ||||||
|   echo "TEST RUNNING $test_name" |   echo "TEST RUNNING $test_name" | ||||||
| @@ -23,6 +25,11 @@ export BASE_URL="https://really-unique-domain.io" | |||||||
| pytest tests/test_notification.py | pytest tests/test_notification.py | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Re-run with HIDE_REFERER set - could affect login | ||||||
|  | export HIDE_REFERER=True | ||||||
|  | pytest tests/test_access_control.py | ||||||
|  |  | ||||||
|  |  | ||||||
| # Now for the selenium and playwright/browserless fetchers | # Now for the selenium and playwright/browserless fetchers | ||||||
| # Note - this is not UI functional tests - just checking that each one can fetch the content | # Note - this is not UI functional tests - just checking that each one can fetch the content | ||||||
|  |  | ||||||
| @@ -32,16 +39,66 @@ docker run -d --name $$-test_selenium  -p 4444:4444 --rm --shm-size="2g"  seleni | |||||||
| sleep 5 | sleep 5 | ||||||
| export WEBDRIVER_URL=http://localhost:4444/wd/hub | export WEBDRIVER_URL=http://localhost:4444/wd/hub | ||||||
| pytest tests/fetchers/test_content.py | pytest tests/fetchers/test_content.py | ||||||
|  | pytest tests/test_errorhandling.py | ||||||
| unset WEBDRIVER_URL | unset WEBDRIVER_URL | ||||||
| docker kill $$-test_selenium | docker kill $$-test_selenium | ||||||
|  |  | ||||||
| echo "TESTING WEBDRIVER FETCH > PLAYWRIGHT/BROWSERLESS..." | echo "TESTING WEBDRIVER FETCH > PLAYWRIGHT/BROWSERLESS..." | ||||||
| # Not all platforms support playwright (not ARM/rPI), so it's not packaged in requirements.txt | # Not all platforms support playwright (not ARM/rPI), so it's not packaged in requirements.txt | ||||||
| pip3 install playwright~=1.22 | PLAYWRIGHT_VERSION=$(grep -i -E "RUN pip install.+" "$SCRIPT_DIR/../Dockerfile" | grep --only-matching -i -E "playwright[=><~+]+[0-9\.]+") | ||||||
|  | echo "using $PLAYWRIGHT_VERSION" | ||||||
|  | pip3 install "$PLAYWRIGHT_VERSION" | ||||||
| docker run -d --name $$-test_browserless -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm  -p 3000:3000  --shm-size="2g"  browserless/chrome:1.53-chrome-stable | docker run -d --name $$-test_browserless -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm  -p 3000:3000  --shm-size="2g"  browserless/chrome:1.53-chrome-stable | ||||||
| # takes a while to spin up | # takes a while to spin up | ||||||
| sleep 5 | sleep 5 | ||||||
| export PLAYWRIGHT_DRIVER_URL=ws://127.0.0.1:3000 | export PLAYWRIGHT_DRIVER_URL=ws://127.0.0.1:3000 | ||||||
| pytest tests/fetchers/test_content.py | pytest tests/fetchers/test_content.py | ||||||
|  | pytest tests/test_errorhandling.py | ||||||
|  | pytest tests/visualselector/test_fetch_data.py | ||||||
|  |  | ||||||
| unset PLAYWRIGHT_DRIVER_URL | unset PLAYWRIGHT_DRIVER_URL | ||||||
| docker kill $$-test_browserless | docker kill $$-test_browserless | ||||||
|  |  | ||||||
|  | # Test proxy list handling, starting two squids on different ports | ||||||
|  | # Each squid adds a different header to the response, which is the main thing we test for. | ||||||
|  | docker run -d --name $$-squid-one --rm -v `pwd`/tests/proxy_list/squid.conf:/etc/squid/conf.d/debian.conf -p 3128:3128 ubuntu/squid:4.13-21.10_edge | ||||||
|  | docker run -d --name $$-squid-two --rm -v `pwd`/tests/proxy_list/squid.conf:/etc/squid/conf.d/debian.conf -p 3129:3128 ubuntu/squid:4.13-21.10_edge | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # So, basic HTTP as env var test | ||||||
|  | export HTTP_PROXY=http://localhost:3128 | ||||||
|  | export HTTPS_PROXY=http://localhost:3128 | ||||||
|  | pytest tests/proxy_list/test_proxy.py | ||||||
|  | docker logs $$-squid-one 2>/dev/null|grep one.changedetection.io | ||||||
|  | if [ $? -ne 0 ] | ||||||
|  | then | ||||||
|  |   echo "Did not see a request to one.changedetection.io in the squid logs (while checking env vars HTTP_PROXY/HTTPS_PROXY)" | ||||||
|  | fi | ||||||
|  | unset HTTP_PROXY | ||||||
|  | unset HTTPS_PROXY | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # 2nd test actually choose the preferred proxy from proxies.json | ||||||
|  | cp tests/proxy_list/proxies.json-example ./test-datastore/proxies.json | ||||||
|  | # Makes a watch use a preferred proxy | ||||||
|  | pytest tests/proxy_list/test_multiple_proxy.py | ||||||
|  |  | ||||||
|  | # Should be a request in the default "first" squid | ||||||
|  | docker logs $$-squid-one 2>/dev/null|grep chosen.changedetection.io | ||||||
|  | if [ $? -ne 0 ] | ||||||
|  | then | ||||||
|  |   echo "Did not see a request to chosen.changedetection.io in the squid logs (while checking preferred proxy)" | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | # And one in the 'second' squid (user selects this as preferred) | ||||||
|  | docker logs $$-squid-two 2>/dev/null|grep chosen.changedetection.io | ||||||
|  | if [ $? -ne 0 ] | ||||||
|  | then | ||||||
|  |   echo "Did not see a request to chosen.changedetection.io in the squid logs (while checking preferred proxy)" | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | # @todo - test system override proxy selection and watch defaults, setup a 3rd squid? | ||||||
|  | docker kill $$-squid-one | ||||||
|  | docker kill $$-squid-two | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/android-chrome-192x192.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 33 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/android-chrome-256x256.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 40 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/apple-touch-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 31 KiB | 
							
								
								
									
										9
									
								
								changedetectionio/static/favicons/browserconfig.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <browserconfig> | ||||||
|  |     <msapplication> | ||||||
|  |         <tile> | ||||||
|  |             <square150x150logo src="favicons/mstile-150x150.png"/> | ||||||
|  |             <TileColor>#da532c</TileColor> | ||||||
|  |         </tile> | ||||||
|  |     </msapplication> | ||||||
|  | </browserconfig> | ||||||
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/favicon-16x16.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 13 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/favicon-32x32.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 14 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/mstile-150x150.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 15 KiB | 
							
								
								
									
										35
									
								
								changedetectionio/static/favicons/safari-pinned-tab.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,35 @@ | |||||||
|  | <?xml version="1.0" standalone="no"?> | ||||||
|  | <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" | ||||||
|  |  "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> | ||||||
|  | <svg version="1.0" xmlns="http://www.w3.org/2000/svg" | ||||||
|  |  width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000" | ||||||
|  |  preserveAspectRatio="xMidYMid meet"> | ||||||
|  | <metadata> | ||||||
|  | Created by potrace 1.14, written by Peter Selinger 2001-2017 | ||||||
|  | </metadata> | ||||||
|  | <g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)" | ||||||
|  | fill="#000000" stroke="none"> | ||||||
|  | <path d="M0 1280 l0 -1280 1280 0 1280 0 0 1280 0 1280 -1280 0 -1280 0 0 | ||||||
|  | -1280z m1555 936 c387 -112 675 -426 741 -810 24 -138 15 -352 -20 -470 -106 | ||||||
|  | -353 -360 -606 -713 -712 -75 -22 -113 -27 -253 -31 -144 -5 -176 -2 -252 16 | ||||||
|  | -316 75 -564 271 -707 557 -67 136 -92 237 -98 401 -7 164 5 253 47 378 106 | ||||||
|  | 315 349 556 665 659 114 37 180 45 350 41 125 -2 165 -7 240 -29z"/> | ||||||
|  | <path d="M1091 2165 c-364 -82 -629 -328 -738 -682 -24 -80 -27 -103 -27 -258 | ||||||
|  | -1 -146 2 -182 21 -251 74 -271 259 -497 508 -621 477 -238 1061 -35 1294 450 | ||||||
|  | 61 126 83 220 88 379 7 194 -15 307 -93 461 -126 251 -340 428 -614 507 -99 | ||||||
|  | 29 -343 37 -439 15z m829 -473 c55 -54 100 -106 100 -116 0 -21 -184 -213 | ||||||
|  | -212 -222 -24 -7 -48 12 -48 38 0 11 26 47 58 80 l57 60 -151 -3 c-145 -4 | ||||||
|  | -152 -5 -190 -31 -22 -15 -78 -73 -124 -128 l-85 -99 -32 31 -32 31 30 38 c17 | ||||||
|  | 22 70 79 117 128 66 67 97 92 127 100 22 6 106 11 188 11 81 0 147 3 147 8 0 | ||||||
|  | 4 -25 31 -55 61 -55 55 -65 77 -43 99 25 25 50 10 148 -86z m-1002 -101 c46 | ||||||
|  | -24 141 -121 312 -321 203 -236 290 -330 322 -346 22 -11 60 -14 169 -12 l141 | ||||||
|  | 3 -51 58 c-28 32 -51 64 -51 71 0 18 21 36 43 36 24 0 217 -193 217 -217 0 | ||||||
|  | -19 -185 -210 -212 -219 -24 -7 -48 12 -48 38 0 10 23 43 50 72 l50 53 -52 7 | ||||||
|  | c-29 3 -93 6 -142 6 -104 0 -152 12 -200 52 -19 15 -135 144 -258 286 -274 | ||||||
|  | 316 -305 347 -354 361 -22 6 -94 11 -161 11 -67 0 -128 3 -137 6 -22 9 -21 61 | ||||||
|  | 2 67 9 3 86 5 170 6 133 1 158 -2 190 -18z m227 -468 c23 -34 17 -43 -103 | ||||||
|  | -172 -119 -128 -131 -133 -343 -129 l-154 3 0 35 c0 34 1 35 50 42 28 3 96 7 | ||||||
|  | 153 7 64 1 115 6 136 15 20 8 71 56 127 120 52 58 99 106 105 106 7 0 20 -12 | ||||||
|  | 29 -27z"/> | ||||||
|  | </g> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 2.0 KiB | 
							
								
								
									
										19
									
								
								changedetectionio/static/favicons/site.webmanifest
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | |||||||
|  | { | ||||||
|  |     "name": "", | ||||||
|  |     "short_name": "", | ||||||
|  |     "icons": [ | ||||||
|  |         { | ||||||
|  |             "src": "android-chrome-192x192.png", | ||||||
|  |             "sizes": "192x192", | ||||||
|  |             "type": "image/png" | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "src": "android-chrome-256x256.png", | ||||||
|  |             "sizes": "256x256", | ||||||
|  |             "type": "image/png" | ||||||
|  |         } | ||||||
|  |     ], | ||||||
|  |     "theme_color": "#ffffff", | ||||||
|  |     "background_color": "#ffffff", | ||||||
|  |     "display": "standalone" | ||||||
|  | } | ||||||
							
								
								
									
										4
									
								
								changedetectionio/static/images/bell-off.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <svg width="15" height="16.363636" viewBox="0 0 15 16.363636" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> | ||||||
|  |   <path d="m 14.318182,11.762045 v 1.1925 H 5.4102273 L 11.849318,7.1140909 C 12.234545,9.1561364 12.54,11.181818 14.318182,11.762045 Z m -6.7984093,4.601591 c 1.0759091,0 2.0256823,-0.955909 2.0256823,-2.045454 H 5.4545455 c 0,1.089545 0.9879545,2.045454 2.0652272,2.045454 z M 15,2.8622727 0.9177273,15.636136 0,14.627045 l 1.8443182,-1.6725 h -1.1625 v -1.1925 C 4.0070455,10.677273 2.1784091,4.5388636 5.3611364,2.6897727 5.8009091,2.4347727 6.0709091,1.9609091 6.0702273,1.4488636 v -0.00205 C 6.0702273,0.64772727 6.7104545,0 7.5,0 8.2895455,0 8.9297727,0.64772727 8.9297727,1.4468182 v 0.00205 C 8.9290909,1.9602319 9.199773,2.4354591 9.638864,2.6897773 10.364318,3.111141 10.827273,3.7568228 11.1525,4.5129591 L 14.085682,1.8531818 Z M 6.8181818,1.3636364 C 6.8181818,1.74 7.1236364,2.0454545 7.5,2.0454545 7.8763636,2.0454545 8.1818182,1.74 8.1818182,1.3636364 8.1818182,0.98795455 7.8763636,0.68181818 7.5,0.68181818 c -0.3763636,0 -0.6818182,0.30613637 -0.6818182,0.68181822 z" id="path2" style="fill:#f8321b;stroke-width:0.681818;fill-opacity:1"/> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 1.2 KiB | 
| Before Width: | Height: | Size: 31 KiB | 
							
								
								
									
										51
									
								
								changedetectionio/static/images/notice.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,51 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||||
|  |  | ||||||
|  | <svg | ||||||
|  |    width="20.108334mm" | ||||||
|  |    height="21.43125mm" | ||||||
|  |    viewBox="0 0 20.108334 21.43125" | ||||||
|  |    version="1.1" | ||||||
|  |    id="svg5" | ||||||
|  |    xmlns:xlink="http://www.w3.org/1999/xlink" | ||||||
|  |    xmlns="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:svg="http://www.w3.org/2000/svg"> | ||||||
|  |   <defs | ||||||
|  |      id="defs2" /> | ||||||
|  |   <g | ||||||
|  |      id="layer1" | ||||||
|  |      transform="translate(-141.05873,-76.816635)"> | ||||||
|  |     <image | ||||||
|  |        width="20.108334" | ||||||
|  |        height="21.43125" | ||||||
|  |        preserveAspectRatio="none" | ||||||
|  |        style="image-rendering:optimizeQuality" | ||||||
|  |        xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEwAAABRCAYAAAB430BuAAAABHNCSVQICAgIfAhkiAAABLxJREFU | ||||||
|  | eJztnN2Z2jgUhl8Z7petIGwF0WMXsFBBoIKwFWS2gmQryKSCJRXsTAUDBTDRVBCmgkAB9tkLexh+ | ||||||
|  | bIONLGwP7xU2RjafpaOjoyNBCxHNQAJEfG5sl+3ZLrAWeAyST5/sF91mFH3bRbZbsAq4ClaQq2B7 | ||||||
|  | iKYnmg9Z318F20ICRnj8pMOd6E3HscNVsATxmQD/oeghPCnDLO26q2AkYin+TQ7XREyyrn3zgu2J | ||||||
|  | BSEjZTBZ179pwQ7EEv7KaoovvFnBUsV6ZHrsd+0WTHhKPV1SLGivYEsA1KEtEs2grFitRjQ65VxP | ||||||
|  | fH5JgEjAKsvXupKwFfYxaYJeSeHcWqVSCuwD7/HQQD8lRHLWDStBWG3slbAElkTc5/lTZdkIJhpN | ||||||
|  | h6/UUZDyzAgZK8PKVoEKErE8HlD0bBVcI2ZqwdBWYbFgAT+g1UZwrBbcvRyIpofHJ1Sh1rQCZt1k | ||||||
|  | lN5msQAm8CoYoFF8KVHOsFtQ5aayExBUhpnopJl6J/3/FREGWCrxmaH40/4z1oyQ320Yf5dDozXC | ||||||
|  | P4QMCRkCY4S5w/tbMTtd4L2Ngo6wJmSQ4hfdScAU+OjgGazgOXEl8oJyof3Z6Spx0iTzgnLKsMoK | ||||||
|  | w9SRuoR3rHniVVMXwRpDXQR7d+kHOJV6CFZB0khVOBGsTcE6VzWsNVGQizfJptU+N4LlD3AbVfsu | ||||||
|  | XsOahhvB8nrB08IrtcGNYNIct+EYl2+S6mr0D8kLUMrV6BfFRTzOGs4Ey8p1aNrUnssaliaMO/vV | ||||||
|  | sfNi3AmW5j54DgUTO/dyJ1hab9iwHhLcNskP23ZMND0kewFBXek6vZvHg/hMiUPSN00z+OBasFig | ||||||
|  | y8wSRfnZ0adSBz+sUVwFK4jbJhnPP06To1ETczpcCnavHhltHd82LU0AXDbJMGXBU8PSBAA8Jxk0 | ||||||
|  | wnNaqlGSJuAyg+dsXIV38iZqXU3iWsmodhetSNlDQgJGriZxbWVSe1hS/gQ+S/C6j4QEfES21vxU | ||||||
|  | icXsoC4vC5mqJvbybyXgduucG/YWaYmmj+IdHvpoxFdt8ltRP5h3iZjRqfBh60C4t1rNY7rxAU95 | ||||||
|  | aYnhEp+/u8pgxGfeRCfyJIR5SkLfFOHYXMMzu63PEDF9WQnSo8MUmhduyUWYEzGyvnRmU3683ugG | ||||||
|  | GAG/2bqJU4RnFDNCpsfWb5chswUnwb5Xg+hxiyo9w7MGJoSVpmYulam+A8scS+5nPYtf+s9mpZw7 | ||||||
|  | J1nayDnCVuu4Ck+E6DqIBYDHHR1+is/n8kVUhfBExMBFMzm4taafkXcWL9BSfBG/nNN8sutYcE3S | ||||||
|  | d7XI3o6lSpIe/xcAIX/svzDxMVu22BAyLNKL2q9hwrdLiZWwXbP6B99GDLaGSpoOD6JPn4yxK1i8 | ||||||
|  | B0StY1zKsCJiQNxzQ0HRbAm2BsZN2TBDGVaE5USzIVjsNix2VrzWHmUwB6J5fD32uyKCzQ7OxG5D | ||||||
|  | vzZuQ0E2osXjRlBMjvWe5WtYPE4b2BynXQJlMEToTUegmEiwM1mzQ1nBvqvH5ov1wlZHcA+AZHdc | ||||||
|  | xQW7vNuQS9kBtzKs1IIRMM7b0q/YvGTzto4qbFutdV5FnLtLk2x3JVWUfXKTbIu9Opc2J6Osj19S | ||||||
|  | HLfJKO64r6rg/wFBX3+2ZapW8wAAAABJRU5ErkJggg== | ||||||
|  | " | ||||||
|  |        id="image832" | ||||||
|  |        x="141.05873" | ||||||
|  |        y="76.816635" /> | ||||||
|  |   </g> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 2.4 KiB | 
							
								
								
									
										122
									
								
								changedetectionio/static/images/play.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,122 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <svg | ||||||
|  |    version="1.1" | ||||||
|  |    id="Capa_1" | ||||||
|  |    x="0px" | ||||||
|  |    y="0px" | ||||||
|  |    viewBox="0 0 15 14.998326" | ||||||
|  |    xml:space="preserve" | ||||||
|  |    width="15" | ||||||
|  |    height="14.998326" | ||||||
|  |    sodipodi:docname="play.svg" | ||||||
|  |    inkscape:version="1.1.1 (1:1.1+202109281949+c3084ef5ed)" | ||||||
|  |    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||||
|  |    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||||
|  |    xmlns="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:svg="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" | ||||||
|  |    xmlns:cc="http://creativecommons.org/ns#" | ||||||
|  |    xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview | ||||||
|  |    id="namedview21" | ||||||
|  |    pagecolor="#ffffff" | ||||||
|  |    bordercolor="#666666" | ||||||
|  |    borderopacity="1.0" | ||||||
|  |    inkscape:pageshadow="2" | ||||||
|  |    inkscape:pageopacity="0.0" | ||||||
|  |    inkscape:pagecheckerboard="0" | ||||||
|  |    showgrid="false" | ||||||
|  |    inkscape:zoom="45.47174" | ||||||
|  |    inkscape:cx="7.4991632" | ||||||
|  |    inkscape:cy="7.4991632" | ||||||
|  |    inkscape:window-width="1554" | ||||||
|  |    inkscape:window-height="896" | ||||||
|  |    inkscape:window-x="3048" | ||||||
|  |    inkscape:window-y="227" | ||||||
|  |    inkscape:window-maximized="0" | ||||||
|  |    inkscape:current-layer="Capa_1" /><metadata | ||||||
|  |    id="metadata39"><rdf:RDF><cc:Work | ||||||
|  |        rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type | ||||||
|  |          rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs | ||||||
|  |    id="defs37" /> | ||||||
|  | <path | ||||||
|  |    id="path2" | ||||||
|  |    style="fill:#1b98f8;fill-opacity:1;stroke-width:0.0292893" | ||||||
|  |    d="M 7.4980469,0 C 4.5496028,-0.04093755 1.7047721,1.8547661 0.58789062,4.5800781 -0.57819305,7.2574082 0.02636631,10.583252 2.0703125,12.671875 4.0368718,14.788335 7.2754393,15.560096 9.9882812,14.572266 12.800219,13.617028 14.874915,10.855516 14.986328,7.8847656 15.172991,4.9968456 13.497714,2.109448 10.910156,0.8203125 9.858961,0.28011352 8.6796569,-0.00179908 7.4980469,0 Z" | ||||||
|  |    sodipodi:nodetypes="ccccccc" /> | ||||||
|  | <g | ||||||
|  |    id="g4" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g6" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g8" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g10" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g12" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g14" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g16" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g18" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g20" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g22" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g24" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g26" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g28" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g30" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <g | ||||||
|  |    id="g32" | ||||||
|  |    transform="translate(-0.01903604,0.02221043)"> | ||||||
|  | </g> | ||||||
|  | <path | ||||||
|  |    sodipodi:type="star" | ||||||
|  |    style="fill:#ffffff;fill-opacity:1;stroke-width:37.7953;paint-order:stroke fill markers" | ||||||
|  |    id="path1203" | ||||||
|  |    inkscape:flatsided="false" | ||||||
|  |    sodipodi:sides="3" | ||||||
|  |    sodipodi:cx="7.2964563" | ||||||
|  |    sodipodi:cy="7.3240671" | ||||||
|  |    sodipodi:r1="3.805218" | ||||||
|  |    sodipodi:r2="1.9026089" | ||||||
|  |    sodipodi:arg1="-0.0017436774" | ||||||
|  |    sodipodi:arg2="1.0454539" | ||||||
|  |    inkscape:rounded="0" | ||||||
|  |    inkscape:randomized="0" | ||||||
|  |    d="M 11.101669,7.317432 8.2506324,8.9701135 5.3995964,10.622795 5.3938504,7.3273846 5.3881041,4.0319742 8.2448863,5.6747033 Z" | ||||||
|  |    inkscape:transform-center-x="-0.94843001" | ||||||
|  |    inkscape:transform-center-y="0.0033175346" /></svg> | ||||||
| After Width: | Height: | Size: 3.5 KiB | 
							
								
								
									
										20
									
								
								changedetectionio/static/images/spread-white.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,20 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <svg | ||||||
|  |    width="18" | ||||||
|  |    height="19.92" | ||||||
|  |    viewBox="0 0 18 19.92" | ||||||
|  |    version="1.1" | ||||||
|  |    id="svg6" | ||||||
|  |    xmlns="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:svg="http://www.w3.org/2000/svg"> | ||||||
|  |   <defs | ||||||
|  |      id="defs10" /> | ||||||
|  |   <path | ||||||
|  |      d="M -3,-2 H 21 V 22 H -3 Z" | ||||||
|  |      fill="none" | ||||||
|  |      id="path2" /> | ||||||
|  |   <path | ||||||
|  |      d="m 15,14.08 c -0.76,0 -1.44,0.3 -1.96,0.77 L 5.91,10.7 C 5.96,10.47 6,10.24 6,10 6,9.76 5.96,9.53 5.91,9.3 L 12.96,5.19 C 13.5,5.69 14.21,6 15,6 16.66,6 18,4.66 18,3 18,1.34 16.66,0 15,0 c -1.66,0 -3,1.34 -3,3 0,0.24 0.04,0.47 0.09,0.7 L 5.04,7.81 C 4.5,7.31 3.79,7 3,7 1.34,7 0,8.34 0,10 c 0,1.66 1.34,3 3,3 0.79,0 1.5,-0.31 2.04,-0.81 l 7.12,4.16 c -0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92 0,-1.61 -1.31,-2.92 -2.92,-2.92 z" | ||||||
|  |      id="path4" | ||||||
|  |      style="fill:#ffffff;fill-opacity:1" /> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 892 B | 
| @@ -1,46 +1,5 @@ | |||||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
| <svg | <svg width="18" height="19.92" viewBox="0 0 18 19.92" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> | ||||||
|    width="18" |   <path d="M -3,-2 H 21 V 22 H -3 Z" fill="none" id="path2"/> | ||||||
|    height="19.92" |   <path d="m 15,14.08 c -0.76,0 -1.44,0.3 -1.96,0.77 L 5.91,10.7 C 5.96,10.47 6,10.24 6,10 6,9.76 5.96,9.53 5.91,9.3 L 12.96,5.19 C 13.5,5.69 14.21,6 15,6 16.66,6 18,4.66 18,3 18,1.34 16.66,0 15,0 c -1.66,0 -3,1.34 -3,3 0,0.24 0.04,0.47 0.09,0.7 L 5.04,7.81 C 4.5,7.31 3.79,7 3,7 1.34,7 0,8.34 0,10 c 0,1.66 1.34,3 3,3 0.79,0 1.5,-0.31 2.04,-0.81 l 7.12,4.16 c -0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92 0,-1.61 -1.31,-2.92 -2.92,-2.92 z" id="path4" style="fill:#0078e7;fill-opacity:1"/> | ||||||
|    viewBox="0 0 18 19.92" |  | ||||||
|    version="1.1" |  | ||||||
|    id="svg6" |  | ||||||
|    sodipodi:docname="spread.svg" |  | ||||||
|    inkscape:version="1.1.1 (1:1.1+202109281949+c3084ef5ed)" |  | ||||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" |  | ||||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" |  | ||||||
|    xmlns="http://www.w3.org/2000/svg" |  | ||||||
|    xmlns:svg="http://www.w3.org/2000/svg"> |  | ||||||
|   <defs |  | ||||||
|      id="defs10" /> |  | ||||||
|   <sodipodi:namedview |  | ||||||
|      id="namedview8" |  | ||||||
|      pagecolor="#ffffff" |  | ||||||
|      bordercolor="#666666" |  | ||||||
|      borderopacity="1.0" |  | ||||||
|      inkscape:pageshadow="2" |  | ||||||
|      inkscape:pageopacity="0.0" |  | ||||||
|      inkscape:pagecheckerboard="0" |  | ||||||
|      showgrid="false" |  | ||||||
|      fit-margin-top="0" |  | ||||||
|      fit-margin-left="0" |  | ||||||
|      fit-margin-right="0" |  | ||||||
|      fit-margin-bottom="0" |  | ||||||
|      inkscape:zoom="28.416667" |  | ||||||
|      inkscape:cx="9.0087975" |  | ||||||
|      inkscape:cy="9.9941348" |  | ||||||
|      inkscape:window-width="1920" |  | ||||||
|      inkscape:window-height="1056" |  | ||||||
|      inkscape:window-x="1920" |  | ||||||
|      inkscape:window-y="0" |  | ||||||
|      inkscape:window-maximized="1" |  | ||||||
|      inkscape:current-layer="svg6" /> |  | ||||||
|   <path |  | ||||||
|      d="M -3,-2 H 21 V 22 H -3 Z" |  | ||||||
|      fill="none" |  | ||||||
|      id="path2" /> |  | ||||||
|   <path |  | ||||||
|      d="m 15,14.08 c -0.76,0 -1.44,0.3 -1.96,0.77 L 5.91,10.7 C 5.96,10.47 6,10.24 6,10 6,9.76 5.96,9.53 5.91,9.3 L 12.96,5.19 C 13.5,5.69 14.21,6 15,6 16.66,6 18,4.66 18,3 18,1.34 16.66,0 15,0 c -1.66,0 -3,1.34 -3,3 0,0.24 0.04,0.47 0.09,0.7 L 5.04,7.81 C 4.5,7.31 3.79,7 3,7 1.34,7 0,8.34 0,10 c 0,1.66 1.34,3 3,3 0.79,0 1.5,-0.31 2.04,-0.81 l 7.12,4.16 c -0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92 0,-1.61 -1.31,-2.92 -2.92,-2.92 z" |  | ||||||
|      id="path4" |  | ||||||
|      style="fill:#0078e7;fill-opacity:1" /> |  | ||||||
| </svg> | </svg> | ||||||
|   | |||||||
| Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 787 B | 
							
								
								
									
										454
									
								
								changedetectionio/static/js/browser-steps.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,454 @@ | |||||||
|  | $(document).ready(function () { | ||||||
|  |  | ||||||
|  |     // duplicate | ||||||
|  |     var csrftoken = $('input[name=csrf_token]').val(); | ||||||
|  |     $.ajaxSetup({ | ||||||
|  |         beforeSend: function (xhr, settings) { | ||||||
|  |             if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { | ||||||
|  |                 xhr.setRequestHeader("X-CSRFToken", csrftoken) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }) | ||||||
|  |     var browsersteps_session_id; | ||||||
|  |     var browserless_seconds_remaining = 0; | ||||||
|  |     var apply_buttons_disabled = false; | ||||||
|  |     var include_text_elements = $("#include_text_elements"); | ||||||
|  |     var xpath_data = false; | ||||||
|  |     var current_selected_i; | ||||||
|  |     var state_clicked = false; | ||||||
|  |     var c; | ||||||
|  |  | ||||||
|  |     // redline highlight context | ||||||
|  |     var ctx; | ||||||
|  |     var last_click_xy = {'x': -1, 'y': -1} | ||||||
|  |  | ||||||
|  |     $(window).resize(function () { | ||||||
|  |         set_scale(); | ||||||
|  |     }); | ||||||
|  |     // Should always be disabled | ||||||
|  |     $('#browser_steps >li:first-child select').val('Goto site').attr('disabled', 'disabled'); | ||||||
|  |  | ||||||
|  |     $('#browsersteps-click-start').click(function () { | ||||||
|  |         $("#browsersteps-click-start").fadeOut(); | ||||||
|  |         $("#browsersteps-selector-wrapper .spinner").fadeIn(); | ||||||
|  |         start(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     $('a#browsersteps-tab').click(function () { | ||||||
|  |         reset(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     window.addEventListener('hashchange', function () { | ||||||
|  |         if (window.location.hash == '#browser-steps') { | ||||||
|  |             reset(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     function reset() { | ||||||
|  |         xpath_data = false; | ||||||
|  |         $('#browsersteps-img').removeAttr('src'); | ||||||
|  |         $("#browsersteps-click-start").show(); | ||||||
|  |         $("#browsersteps-selector-wrapper .spinner").hide(); | ||||||
|  |         browserless_seconds_remaining = 0; | ||||||
|  |         browsersteps_session_id = false; | ||||||
|  |         apply_buttons_disabled = false; | ||||||
|  |         ctx.clearRect(0, 0, c.width, c.height); | ||||||
|  |         set_first_gotosite_disabled(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function set_first_gotosite_disabled() { | ||||||
|  |         $('#browser_steps >li:first-child select').val('Goto site').attr('disabled', 'disabled'); | ||||||
|  |         $('#browser_steps >li:first-child').css('opacity', '0.5'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Show seconds remaining until playwright/browserless needs to restart the session | ||||||
|  |     // (See comment at the top of changedetectionio/blueprint/browser_steps/__init__.py ) | ||||||
|  |     setInterval(() => { | ||||||
|  |         if (browserless_seconds_remaining >= 1) { | ||||||
|  |             document.getElementById('browserless-seconds-remaining').innerText = browserless_seconds_remaining + " seconds remaining in session"; | ||||||
|  |             browserless_seconds_remaining -= 1; | ||||||
|  |         } | ||||||
|  |     }, "1000") | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     function set_scale() { | ||||||
|  |  | ||||||
|  |         // some things to check if the scaling doesnt work | ||||||
|  |         // - that the widths/sizes really are about the actual screen size cat elements.json |grep -o width......|sort|uniq | ||||||
|  |         selector_image = $("img#browsersteps-img")[0]; | ||||||
|  |         selector_image_rect = selector_image.getBoundingClientRect(); | ||||||
|  |  | ||||||
|  |         // make the canvas and input steps the same size as the image | ||||||
|  |         $('#browsersteps-selector-canvas').attr('height', selector_image_rect.height).attr('width', selector_image_rect.width); | ||||||
|  |         //$('#browsersteps-selector-wrapper').attr('width', selector_image_rect.width); | ||||||
|  |         $('#browser-steps-ui').attr('width', selector_image_rect.width); | ||||||
|  |  | ||||||
|  |         x_scale = selector_image_rect.width / xpath_data['browser_width']; | ||||||
|  |         y_scale = selector_image_rect.height / selector_image.naturalHeight; | ||||||
|  |         ctx.strokeStyle = 'rgba(255,0,0, 0.9)'; | ||||||
|  |         ctx.fillStyle = 'rgba(255,0,0, 0.1)'; | ||||||
|  |         ctx.lineWidth = 3; | ||||||
|  |         console.log("scaling set  x: " + x_scale + " by y:" + y_scale); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // bootstrap it, this will trigger everything else | ||||||
|  |     $('#browsersteps-img').bind('load', function () { | ||||||
|  |         $('body').addClass('full-width'); | ||||||
|  |         console.log("Loaded background..."); | ||||||
|  |  | ||||||
|  |         document.getElementById("browsersteps-selector-canvas"); | ||||||
|  |         c = document.getElementById("browsersteps-selector-canvas"); | ||||||
|  |         // redline highlight context | ||||||
|  |         ctx = c.getContext("2d"); | ||||||
|  |         // @todo is click better? | ||||||
|  |         $('#browsersteps-selector-canvas').off("mousemove mousedown click"); | ||||||
|  |         // Undo disable_browsersteps_ui | ||||||
|  |         $("#browser-steps-ui").css('opacity', '1.0'); | ||||||
|  |  | ||||||
|  |         // init | ||||||
|  |         set_scale(); | ||||||
|  |  | ||||||
|  |         // @todo click ? some better library? | ||||||
|  |         $('#browsersteps-selector-canvas').bind('click', function (e) { | ||||||
|  |             // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent | ||||||
|  |             e.preventDefault() | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         $('#browsersteps-selector-canvas').bind('mousedown', function (e) { | ||||||
|  |             // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent | ||||||
|  |             e.preventDefault() | ||||||
|  |             console.log(e); | ||||||
|  |             console.log("current xpath in index is " + current_selected_i); | ||||||
|  |             last_click_xy = {'x': parseInt((1 / x_scale) * e.offsetX), 'y': parseInt((1 / y_scale) * e.offsetY)} | ||||||
|  |             process_selected(current_selected_i); | ||||||
|  |             current_selected_i = false; | ||||||
|  |  | ||||||
|  |             // if process selected returned false, then best we can do is offer a x,y click :( | ||||||
|  |             if (!found_something) { | ||||||
|  |                 var first_available = $("ul#browser_steps li.empty").first(); | ||||||
|  |                 $('select', first_available).val('Click X,Y').change(); | ||||||
|  |                 $('input[type=text]', first_available).first().val(last_click_xy['x'] + ',' + last_click_xy['y']); | ||||||
|  |                 draw_circle_on_canvas(e.offsetX, e.offsetY); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         $('#browsersteps-selector-canvas').bind('mousemove', function (e) { | ||||||
|  |             if (!xpath_data) { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // checkbox if find elements is enabled | ||||||
|  |             ctx.clearRect(0, 0, c.width, c.height); | ||||||
|  |             ctx.fillStyle = 'rgba(255,0,0, 0.1)'; | ||||||
|  |             ctx.strokeStyle = 'rgba(255,0,0, 0.9)'; | ||||||
|  |  | ||||||
|  |             // Add in offset | ||||||
|  |             if ((typeof e.offsetX === "undefined" || typeof e.offsetY === "undefined") || (e.offsetX === 0 && e.offsetY === 0)) { | ||||||
|  |                 var targetOffset = $(e.target).offset(); | ||||||
|  |                 e.offsetX = e.pageX - targetOffset.left; | ||||||
|  |                 e.offsetY = e.pageY - targetOffset.top; | ||||||
|  |             } | ||||||
|  |             current_selected_i = false; | ||||||
|  |             // Reverse order - the most specific one should be deeper/"laster" | ||||||
|  |             // Basically, find the most 'deepest' | ||||||
|  |             //$('#browsersteps-selector-canvas').css('cursor', 'pointer'); | ||||||
|  |             for (var i = xpath_data['size_pos'].length; i !== 0; i--) { | ||||||
|  |                 // draw all of them? let them choose somehow? | ||||||
|  |                 var sel = xpath_data['size_pos'][i - 1]; | ||||||
|  |                 // If we are in a bounding-box | ||||||
|  |                 if (e.offsetY > sel.top * y_scale && e.offsetY < sel.top * y_scale + sel.height * y_scale | ||||||
|  |                     && | ||||||
|  |                     e.offsetX > sel.left * y_scale && e.offsetX < sel.left * y_scale + sel.width * y_scale | ||||||
|  |  | ||||||
|  |                 ) { | ||||||
|  |                     // Only highlight these interesting types | ||||||
|  |                     if (1) { | ||||||
|  |                         ctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||||
|  |                         ctx.fillRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||||
|  |                         current_selected_i = i - 1; | ||||||
|  |                         break; | ||||||
|  |  | ||||||
|  |                         // find the smallest one at this x,y | ||||||
|  |                         // does it mean sort the xpath list by size (w*h) i think so! | ||||||
|  |                     } else { | ||||||
|  |  | ||||||
|  |                         if (include_text_elements[0].checked === true) { | ||||||
|  |                             // blue one with background instead? | ||||||
|  |                             ctx.fillStyle = 'rgba(0,0,255, 0.1)'; | ||||||
|  |                             ctx.strokeStyle = 'rgba(0,0,200, 0.7)'; | ||||||
|  |                             $('#browsersteps-selector-canvas').css('cursor', 'grab'); | ||||||
|  |                             ctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||||
|  |                             ctx.fillRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||||
|  |                             current_selected_i = i - 1; | ||||||
|  |                             break; | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |         }.debounce(10)); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  | //    $("#browser-steps-fieldlist").bind('mouseover', function(e) { | ||||||
|  | //        console.log(e.xpath_data_index); | ||||||
|  |     // }); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     // callback for clicking on an xpath on the canvas | ||||||
|  |     function process_selected(xpath_data_index) { | ||||||
|  |         found_something = false; | ||||||
|  |         var first_available = $("ul#browser_steps li.empty").first(); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         if (xpath_data_index !== false) { | ||||||
|  |             // Nothing focused, so fill in a new one | ||||||
|  |             // if inpt type button or <button> | ||||||
|  |             // from the top, find the next not used one and use it | ||||||
|  |             var x = xpath_data['size_pos'][xpath_data_index]; | ||||||
|  |             console.log(x); | ||||||
|  |             if (x && first_available.length) { | ||||||
|  |                 // @todo will it let you click shit that has a layer ontop? probably not. | ||||||
|  |                 if (x['tagtype'] === 'text' || x['tagtype'] === 'email' || x['tagName'] === 'textarea' || x['tagtype'] === 'password' || x['tagtype'] === 'search') { | ||||||
|  |                     $('select', first_available).val('Enter text in field').change(); | ||||||
|  |                     $('input[type=text]', first_available).first().val(x['xpath']); | ||||||
|  |                     $('input[placeholder="Value"]', first_available).addClass('ok').click().focus(); | ||||||
|  |                     found_something = true; | ||||||
|  |                 } else { | ||||||
|  |                     if (x['isClickable'] || x['tagName'].startsWith('h') || x['tagName'] === 'a' || x['tagName'] === 'button' || x['tagtype'] === 'submit' || x['tagtype'] === 'checkbox' || x['tagtype'] === 'radio' || x['tagtype'] === 'li') { | ||||||
|  |                         $('select', first_available).val('Click element').change(); | ||||||
|  |                         $('input[type=text]', first_available).first().val(x['xpath']); | ||||||
|  |                         found_something = true; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 first_available.xpath_data_index = xpath_data_index; | ||||||
|  |  | ||||||
|  |                 if (!found_something) { | ||||||
|  |                     if (include_text_elements[0].checked === true) { | ||||||
|  |                         // Suggest that we use as filter? | ||||||
|  |                         // @todo filters should always be in the last steps, nothing non-filter after it | ||||||
|  |                         found_something = true; | ||||||
|  |                         ctx.strokeStyle = 'rgba(0,0,255, 0.9)'; | ||||||
|  |                         ctx.fillStyle = 'rgba(0,0,255, 0.1)'; | ||||||
|  |                         $('select', first_available).val('Extract text and use as filter').change(); | ||||||
|  |                         $('input[type=text]', first_available).first().val(x['xpath']); | ||||||
|  |                         include_text_elements[0].checked = false; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function draw_circle_on_canvas(x, y) { | ||||||
|  |         ctx.beginPath(); | ||||||
|  |         ctx.arc(x, y, 8, 0, 2 * Math.PI, false); | ||||||
|  |         ctx.fillStyle = 'rgba(255,0,0, 0.6)'; | ||||||
|  |         ctx.fill(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function start() { | ||||||
|  |         console.log("Starting browser-steps UI"); | ||||||
|  |         browsersteps_session_id = Date.now(); | ||||||
|  |         // @todo This setting of the first one should be done at the datalayer but wtforms doesnt wanna play nice | ||||||
|  |         $('#browser_steps >li:first-child').removeClass('empty'); | ||||||
|  |         set_first_gotosite_disabled(); | ||||||
|  |         $('#browser-steps-ui .loader .spinner').show(); | ||||||
|  |         $('.clear,.remove', $('#browser_steps >li:first-child')).hide(); | ||||||
|  |         $.ajax({ | ||||||
|  |             type: "GET", | ||||||
|  |             url: browser_steps_sync_url + "&browsersteps_session_id=" + browsersteps_session_id, | ||||||
|  |             statusCode: { | ||||||
|  |                 400: function () { | ||||||
|  |                     // More than likely the CSRF token was lost when the server restarted | ||||||
|  |                     alert("There was a problem processing the request, please reload the page."); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }).done(function (data) { | ||||||
|  |             xpath_data = data.xpath_data; | ||||||
|  |             $("#loading-status-text").fadeIn(); | ||||||
|  |             // This should trigger 'Goto site' | ||||||
|  |             console.log("Got startup response, requesting Goto-Site (first) step fake click"); | ||||||
|  |             $('#browser_steps >li:first-child .apply').click(); | ||||||
|  |             browserless_seconds_remaining = data.browser_time_remaining; | ||||||
|  |             set_first_gotosite_disabled(); | ||||||
|  |         }).fail(function (data) { | ||||||
|  |             console.log(data); | ||||||
|  |             alert('There was an error communicating with the server.'); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function disable_browsersteps_ui() { | ||||||
|  |         set_first_gotosite_disabled(); | ||||||
|  |         $("#browser-steps-ui").css('opacity', '0.3'); | ||||||
|  |         $('#browsersteps-selector-canvas').off("mousemove mousedown click"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     ////////////////////////// STEPS UI //////////////////// | ||||||
|  |     $('ul#browser_steps [type="text"]').keydown(function (e) { | ||||||
|  |         if (e.keyCode === 13) { | ||||||
|  |             // hitting [enter] in a browser-step input should trigger the 'Apply' | ||||||
|  |             e.preventDefault(); | ||||||
|  |             $(".apply", $(this).closest('li')).click(); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // Look up which step was selected, and enable or disable the related extra fields | ||||||
|  |     // So that people using it dont' get confused | ||||||
|  |     $('ul#browser_steps select').on("change", function () { | ||||||
|  |         var config = browser_steps_config[$(this).val()].split(' '); | ||||||
|  |         var elem_selector = $('tr:nth-child(2) input', $(this).closest('tbody')); | ||||||
|  |         var elem_value = $('tr:nth-child(3) input', $(this).closest('tbody')); | ||||||
|  |  | ||||||
|  |         if (config[0] == 0) { | ||||||
|  |             $(elem_selector).fadeOut(); | ||||||
|  |         } else { | ||||||
|  |             $(elem_selector).fadeIn(); | ||||||
|  |         } | ||||||
|  |         if (config[1] == 0) { | ||||||
|  |             $(elem_value).fadeOut(); | ||||||
|  |         } else { | ||||||
|  |             $(elem_value).fadeIn(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if ($(this).val() === 'Click X,Y' && last_click_xy['x'] > 0 && $(elem_value).val().length === 0) { | ||||||
|  |             // @todo handle scale | ||||||
|  |             $(elem_value).val(last_click_xy['x'] + ',' + last_click_xy['y']); | ||||||
|  |         } | ||||||
|  |     }).change(); | ||||||
|  |  | ||||||
|  |     function set_greyed_state() { | ||||||
|  |         $('ul#browser_steps select').not('option:selected[value="Choose one"]').closest('li').removeClass('empty'); | ||||||
|  |         $('ul#browser_steps select option:selected[value="Choose one"]').closest('li').addClass('empty'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Add the extra buttons to the steps | ||||||
|  |     $('ul#browser_steps li').each(function (i) { | ||||||
|  |             var s = '<div class="control">' + '<a data-step-index=' + i + ' class="pure-button button-secondary button-green button-xsmall apply" >Apply</a> '; | ||||||
|  |             if (i > 0) { | ||||||
|  |                 // The first step never gets these (Goto-site) | ||||||
|  |                 s += '<a data-step-index=' + i + ' class="pure-button button-secondary button-xsmall clear" >Clear</a> ' + | ||||||
|  |                     '<a data-step-index=' + i + ' class="pure-button button-secondary button-red button-xsmall remove" >Remove</a>'; | ||||||
|  |             } | ||||||
|  |             s += '</div>'; | ||||||
|  |             $(this).append(s) | ||||||
|  |         } | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     $('ul#browser_steps li .control .clear').click(function (element) { | ||||||
|  |         $("select", $(this).closest('li')).val("Choose one").change(); | ||||||
|  |         $(":text", $(this).closest('li')).val(''); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     $('ul#browser_steps li .control .remove').click(function (element) { | ||||||
|  |         // so you wanna remove the 2nd (3rd spot 0,1,2,...) | ||||||
|  |         var p = $("#browser_steps li").index($(this).closest('li')); | ||||||
|  |  | ||||||
|  |         var elem_to_remove = $("#browser_steps li")[p]; | ||||||
|  |         $('.clear', elem_to_remove).click(); | ||||||
|  |         $("#browser_steps li").slice(p, 10).each(function (index) { | ||||||
|  |             // get the next one's value from where we clicked | ||||||
|  |             var next = $("#browser_steps li")[p + index + 1]; | ||||||
|  |             if (next) { | ||||||
|  |                 // and set THIS ones value from the next one | ||||||
|  |                 var n = $('input', next); | ||||||
|  |                 $("select", $(this)).val($('select', next).val()); | ||||||
|  |                 $('input', this)[0].value = $(n)[0].value; | ||||||
|  |                 $('input', this)[1].value = $(n)[1].value; | ||||||
|  |                 // Triggers reconfiguring the field based on the system config | ||||||
|  |                 $("select", $(this)).change(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Reset their hidden/empty states | ||||||
|  |         set_greyed_state(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     $('ul#browser_steps li .control .apply').click(function (event) { | ||||||
|  |         // sequential requests @todo refactor | ||||||
|  |         if (apply_buttons_disabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var current_data = $(event.currentTarget).closest('li'); | ||||||
|  |         $('#browser-steps-ui .loader .spinner').fadeIn(); | ||||||
|  |         apply_buttons_disabled = true; | ||||||
|  |         $('ul#browser_steps li .control .apply').css('opacity', 0.5); | ||||||
|  |         $("#browsersteps-img").css('opacity', 0.65); | ||||||
|  |  | ||||||
|  |         var is_last_step = 0; | ||||||
|  |         var step_n = $(event.currentTarget).data('step-index'); | ||||||
|  |  | ||||||
|  |         // On the last step, we should also be getting data ready for the visual selector | ||||||
|  |         $('ul#browser_steps li select').each(function (i) { | ||||||
|  |             if ($(this).val() !== 'Choose one') { | ||||||
|  |                 is_last_step += 1; | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         if (is_last_step == (step_n + 1)) { | ||||||
|  |             is_last_step = true; | ||||||
|  |         } else { | ||||||
|  |             is_last_step = false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         console.log("Requesting step via POST " + $("select[id$='operation']", current_data).first().val()); | ||||||
|  |         // POST the currently clicked step form widget back and await response, redraw | ||||||
|  |         $.ajax({ | ||||||
|  |             method: "POST", | ||||||
|  |             url: browser_steps_sync_url + "&browsersteps_session_id=" + browsersteps_session_id, | ||||||
|  |             data: { | ||||||
|  |                 'operation': $("select[id$='operation']", current_data).first().val(), | ||||||
|  |                 'selector': $("input[id$='selector']", current_data).first().val(), | ||||||
|  |                 'optional_value': $("input[id$='optional_value']", current_data).first().val(), | ||||||
|  |                 'step_n': step_n, | ||||||
|  |                 'is_last_step': is_last_step | ||||||
|  |             }, | ||||||
|  |             statusCode: { | ||||||
|  |                 400: function () { | ||||||
|  |                     // More than likely the CSRF token was lost when the server restarted | ||||||
|  |                     alert("There was a problem processing the request, please reload the page."); | ||||||
|  |                     $("#loading-status-text").hide(); | ||||||
|  |                     $('#browser-steps-ui .loader .spinner').fadeOut(); | ||||||
|  |                 }, | ||||||
|  |                 401: function (data) { | ||||||
|  |                     // More than likely the CSRF token was lost when the server restarted | ||||||
|  |                     alert(data.responseText); | ||||||
|  |                     $("#loading-status-text").hide(); | ||||||
|  |                     $('#browser-steps-ui .loader .spinner').fadeOut(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }).done(function (data) { | ||||||
|  |             // it should return the new state (selectors available and screenshot) | ||||||
|  |             xpath_data = data.xpath_data; | ||||||
|  |             $('#browsersteps-img').attr('src', data.screenshot); | ||||||
|  |             $('#browser-steps-ui .loader .spinner').fadeOut(); | ||||||
|  |             apply_buttons_disabled = false; | ||||||
|  |             $("#browsersteps-img").css('opacity', 1); | ||||||
|  |             $('ul#browser_steps li .control .apply').css('opacity', 1); | ||||||
|  |             browserless_seconds_remaining = data.browser_time_remaining; | ||||||
|  |             $("#loading-status-text").hide(); | ||||||
|  |             set_first_gotosite_disabled(); | ||||||
|  |         }).fail(function (data) { | ||||||
|  |             console.log(data); | ||||||
|  |             if (data.responseText.includes("Browser session expired")) { | ||||||
|  |                 disable_browsersteps_ui(); | ||||||
|  |             } | ||||||
|  |             apply_buttons_disabled = false; | ||||||
|  |             $("#loading-status-text").hide(); | ||||||
|  |             $('ul#browser_steps li .control .apply').css('opacity', 1); | ||||||
|  |             $("#browsersteps-img").css('opacity', 1); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     $("ul#browser_steps select").change(function () { | ||||||
|  |         set_greyed_state(); | ||||||
|  |     }).change(); | ||||||
|  |  | ||||||
|  | }); | ||||||
							
								
								
									
										23
									
								
								changedetectionio/static/js/diff-overview.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,23 @@ | |||||||
|  | $(document).ready(function () { | ||||||
|  |     // 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); | ||||||
|  |     }, false); | ||||||
|  |  | ||||||
|  |     toggle(location.hash); | ||||||
|  |  | ||||||
|  |     function toggle(hash_name) { | ||||||
|  |         if (hash_name === '#screenshot') { | ||||||
|  |             $("img#screenshot-img").attr('src', screenshot_url); | ||||||
|  |             $("#settings").hide(); | ||||||
|  |         } else if (hash_name === '#error-screenshot') { | ||||||
|  |             $("img#error-screenshot-img").attr('src', error_screenshot_url); | ||||||
|  |             $("#settings").hide(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         else { | ||||||
|  |             $("#settings").show(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | }); | ||||||
							
								
								
									
										110
									
								
								changedetectionio/static/js/diff-render.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,110 @@ | |||||||
|  | var a = document.getElementById("a"); | ||||||
|  | var b = document.getElementById("b"); | ||||||
|  | var result = document.getElementById("result"); | ||||||
|  |  | ||||||
|  | function changed() { | ||||||
|  |   // https://github.com/kpdecker/jsdiff/issues/389 | ||||||
|  |   // I would love to use `{ignoreWhitespace: true}` here but it breaks the formatting | ||||||
|  |   options = { | ||||||
|  |     ignoreWhitespace: document.getElementById("ignoreWhitespace").checked, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   var diff = Diff[window.diffType](a.textContent, b.textContent, options); | ||||||
|  |   var fragment = document.createDocumentFragment(); | ||||||
|  |   for (var i = 0; i < diff.length; i++) { | ||||||
|  |     if (diff[i].added && diff[i + 1] && diff[i + 1].removed) { | ||||||
|  |       var swap = diff[i]; | ||||||
|  |       diff[i] = diff[i + 1]; | ||||||
|  |       diff[i + 1] = swap; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var node; | ||||||
|  |     if (diff[i].removed) { | ||||||
|  |       node = document.createElement("del"); | ||||||
|  |       node.classList.add("change"); | ||||||
|  |       const wrapper = node.appendChild(document.createElement("span")); | ||||||
|  |       wrapper.appendChild(document.createTextNode(diff[i].value)); | ||||||
|  |     } else if (diff[i].added) { | ||||||
|  |       node = document.createElement("ins"); | ||||||
|  |       node.classList.add("change"); | ||||||
|  |       const wrapper = node.appendChild(document.createElement("span")); | ||||||
|  |       wrapper.appendChild(document.createTextNode(diff[i].value)); | ||||||
|  |     } else { | ||||||
|  |       node = document.createTextNode(diff[i].value); | ||||||
|  |     } | ||||||
|  |     fragment.appendChild(node); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   result.textContent = ""; | ||||||
|  |   result.appendChild(fragment); | ||||||
|  |  | ||||||
|  |   // Jump at start | ||||||
|  |   inputs.current = 0; | ||||||
|  |   next_diff(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | window.onload = function () { | ||||||
|  |   /* Convert what is options from UTC time.time() to local browser time */ | ||||||
|  |   var diffList = document.getElementById("diff-version"); | ||||||
|  |   if (typeof diffList != "undefined" && diffList != null) { | ||||||
|  |     for (var option of diffList.options) { | ||||||
|  |       var dateObject = new Date(option.value * 1000); | ||||||
|  |       option.label = dateObject.toLocaleString(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* Set current version date as local time in the browser also */ | ||||||
|  |   var current_v = document.getElementById("current-v-date"); | ||||||
|  |   var dateObject = new Date(newest_version_timestamp * 1000); | ||||||
|  |   current_v.innerHTML = dateObject.toLocaleString(); | ||||||
|  |   onDiffTypeChange( | ||||||
|  |     document.querySelector('#settings [name="diff_type"]:checked'), | ||||||
|  |   ); | ||||||
|  |   changed(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | a.onpaste = a.onchange = b.onpaste = b.onchange = changed; | ||||||
|  |  | ||||||
|  | if ("oninput" in a) { | ||||||
|  |   a.oninput = b.oninput = changed; | ||||||
|  | } else { | ||||||
|  |   a.onkeyup = b.onkeyup = changed; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function onDiffTypeChange(radio) { | ||||||
|  |   window.diffType = radio.value; | ||||||
|  |   // Not necessary | ||||||
|  |   //	document.title = "Diff " + radio.value.slice(4); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | var radio = document.getElementsByName("diff_type"); | ||||||
|  | for (var i = 0; i < radio.length; i++) { | ||||||
|  |   radio[i].onchange = function (e) { | ||||||
|  |     onDiffTypeChange(e.target); | ||||||
|  |     changed(); | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | document.getElementById("ignoreWhitespace").onchange = function (e) { | ||||||
|  |   changed(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | var inputs = document.getElementsByClassName("change"); | ||||||
|  | inputs.current = 0; | ||||||
|  |  | ||||||
|  | function next_diff() { | ||||||
|  |   var element = inputs[inputs.current]; | ||||||
|  |   var headerOffset = 80; | ||||||
|  |   var elementPosition = element.getBoundingClientRect().top; | ||||||
|  |   var offsetPosition = elementPosition - headerOffset + window.scrollY; | ||||||
|  |  | ||||||
|  |   window.scrollTo({ | ||||||
|  |     top: offsetPosition, | ||||||
|  |     behavior: "smooth", | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   inputs.current++; | ||||||
|  |   if (inputs.current >= inputs.length) { | ||||||
|  |     inputs.current = 0; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										38
									
								
								changedetectionio/static/js/diff.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -40,13 +40,19 @@ $(document).ready(function() { | |||||||
|     $.ajax({ |     $.ajax({ | ||||||
|       type: "POST", |       type: "POST", | ||||||
|       url: notification_base_url, |       url: notification_base_url, | ||||||
|       data : data |       data : data, | ||||||
|  |         statusCode: { | ||||||
|  |         400: function() { | ||||||
|  |             // More than likely the CSRF token was lost when the server restarted | ||||||
|  |           alert("There was a problem processing the request, please reload the page."); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|     }).done(function(data){ |     }).done(function(data){ | ||||||
|       console.log(data); |       console.log(data); | ||||||
|       alert('Sent'); |       alert('Sent'); | ||||||
|     }).fail(function(data){ |     }).fail(function(data){ | ||||||
|       console.log(data); |       console.log(data); | ||||||
|       alert('Error: '+data.responseJSON.error); |       alert('There was an error communicating with the server.'); | ||||||
|     }) |     }) | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
							
								
								
									
										34
									
								
								changedetectionio/static/js/stepper.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,34 @@ | |||||||
|  | $(document).ready(function(){ | ||||||
|  |    checkUserVal(); | ||||||
|  |    $('#fetch_backend input').on('change', checkUserVal); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | var checkUserVal = function(){ | ||||||
|  |     if($('#fetch_backend input:checked').val()=='html_requests') { | ||||||
|  |       $('#request-override').show(); | ||||||
|  |       $('#webdriver-stepper').hide(); | ||||||
|  |     } else { | ||||||
|  |       $('#request-override').hide(); | ||||||
|  |       $('#webdriver-stepper').show(); | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | $('a.row-options').on('click', function(){ | ||||||
|  |     var row=$(this.closest('tr')); | ||||||
|  |     switch($(this).data("action")) { | ||||||
|  |       case 'remove': | ||||||
|  |         $(row).remove(); | ||||||
|  |       break; | ||||||
|  |       case 'add': | ||||||
|  |         var new_row=$(row).clone(true).insertAfter($(row)); | ||||||
|  |         $('input', new_new).val(""); | ||||||
|  |       break; | ||||||
|  |       case 'add': | ||||||
|  |         var new_row=$(row).clone(true).insertAfter($(row)); | ||||||
|  |         $('input', new_new).val(""); | ||||||
|  |       break; | ||||||
|  |       case 'resend-step': | ||||||
|  |  | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  | }); | ||||||
| @@ -1,55 +1,49 @@ | |||||||
| // Rewrite this is a plugin.. is all this JS really 'worth it?' | // Rewrite this is a plugin.. is all this JS really 'worth it?' | ||||||
|  |  | ||||||
|  | window.addEventListener('hashchange', function () { | ||||||
| if(!window.location.hash) { |     var tabs = document.getElementsByClassName('active'); | ||||||
|   var tab=document.querySelectorAll("#default-tab a"); |     while (tabs[0]) { | ||||||
|   tab[0].click(); |         tabs[0].classList.remove('active'); | ||||||
| } |         document.body.classList.remove('full-width'); | ||||||
|  |     } | ||||||
| window.addEventListener('hashchange', function() { |     set_active_tab(); | ||||||
|   var tabs = document.getElementsByClassName('active'); |  | ||||||
|   while (tabs[0]) { |  | ||||||
|     tabs[0].classList.remove('active') |  | ||||||
|   } |  | ||||||
|   set_active_tab(); |  | ||||||
| }, false); | }, false); | ||||||
|  |  | ||||||
| var has_errors=document.querySelectorAll(".messages .error"); | var has_errors = document.querySelectorAll(".messages .error"); | ||||||
| if (!has_errors.length) { | if (!has_errors.length) { | ||||||
|     if (document.location.hash == "" ) { |     if (document.location.hash == "") { | ||||||
|         document.location.hash = "#general"; |         document.querySelector(".tabs ul li:first-child a").click(); | ||||||
|         document.getElementById("default-tab").className = "active"; |  | ||||||
|     } else { |     } else { | ||||||
|         set_active_tab(); |         set_active_tab(); | ||||||
|     } |     } | ||||||
| } else { | } else { | ||||||
|   focus_error_tab(); |     focus_error_tab(); | ||||||
| } | } | ||||||
|  |  | ||||||
| function set_active_tab() { | function set_active_tab() { | ||||||
|   var tab=document.querySelectorAll("a[href='"+location.hash+"']"); |     document.body.classList.remove('full-width'); | ||||||
|   if (tab.length) { |     var tab = document.querySelectorAll("a[href='" + location.hash + "']"); | ||||||
|     tab[0].parentElement.className="active"; |     if (tab.length) { | ||||||
|   } |         tab[0].parentElement.className = "active"; | ||||||
|  |     } | ||||||
|     // hash could move the page down |     // hash could move the page down | ||||||
|     window.scrollTo(0, 0); |     window.scrollTo(0, 0); | ||||||
| } | } | ||||||
|  |  | ||||||
| function focus_error_tab() { | function focus_error_tab() { | ||||||
|   // time to use jquery or vuejs really, |     // time to use jquery or vuejs really, | ||||||
|   // activate the tab with the error |     // activate the tab with the error | ||||||
|     var tabs = document.querySelectorAll('.tabs li a'),i; |     var tabs = document.querySelectorAll('.tabs li a'), i; | ||||||
|     for (i = 0; i < tabs.length; ++i) { |     for (i = 0; i < tabs.length; ++i) { | ||||||
|       var tab_name=tabs[i].hash.replace('#',''); |         var tab_name = tabs[i].hash.replace('#', ''); | ||||||
|       var pane_errors=document.querySelectorAll('#'+tab_name+' .error') |         var pane_errors = document.querySelectorAll('#' + tab_name + ' .error') | ||||||
|       if (pane_errors.length) { |         if (pane_errors.length) { | ||||||
|         document.location.hash = '#'+tab_name; |             document.location.hash = '#' + tab_name; | ||||||
|         return true; |             return true; | ||||||
|       } |         } | ||||||
|     } |     } | ||||||
|     return false; |     return false; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										24
									
								
								changedetectionio/static/js/toggle-theme.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,24 @@ | |||||||
|  | /** | ||||||
|  |  * @file | ||||||
|  |  * Toggles theme between light and dark mode. | ||||||
|  |  */ | ||||||
|  | $(document).ready(function () { | ||||||
|  |   const button = document.getElementsByClassName("toggle-theme")[0]; | ||||||
|  |  | ||||||
|  |   button.onclick = () => { | ||||||
|  |     const htmlElement = document.getElementsByTagName("html"); | ||||||
|  |     const isDarkMode = htmlElement[0].dataset.darkmode === "true"; | ||||||
|  |     htmlElement[0].dataset.darkmode = !isDarkMode; | ||||||
|  |     if (isDarkMode) { | ||||||
|  |       button.classList.remove("dark"); | ||||||
|  |       setCookieValue(false); | ||||||
|  |     } else { | ||||||
|  |       button.classList.add("dark"); | ||||||
|  |       setCookieValue(true); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const setCookieValue = (value) => { | ||||||
|  |     document.cookie = `css_dark_mode=${value};max-age=31536000` | ||||||
|  |   } | ||||||
|  | }); | ||||||
| @@ -1,10 +1,10 @@ | |||||||
| // Horrible proof of concept code :) | // Horrible proof of concept code :) | ||||||
| // yes - this is really a hack, if you are a front-ender and want to help, please get in touch! | // yes - this is really a hack, if you are a front-ender and want to help, please get in touch! | ||||||
|  |  | ||||||
| $(document).ready(function() { | $(document).ready(function () { | ||||||
|  |  | ||||||
|     var current_selected_i; |     var current_selected_i; | ||||||
|     var state_clicked=false; |     var state_clicked = false; | ||||||
|  |  | ||||||
|     var c; |     var c; | ||||||
|  |  | ||||||
| @@ -13,9 +13,9 @@ $(document).ready(function() { | |||||||
|     // redline highlight context |     // redline highlight context | ||||||
|     var ctx; |     var ctx; | ||||||
|  |  | ||||||
|     var current_default_xpath; |     var current_default_xpath = []; | ||||||
|     var x_scale=1; |     var x_scale = 1; | ||||||
|     var y_scale=1; |     var y_scale = 1; | ||||||
|     var selector_image; |     var selector_image; | ||||||
|     var selector_image_rect; |     var selector_image_rect; | ||||||
|     var selector_data; |     var selector_data; | ||||||
| @@ -27,202 +27,216 @@ $(document).ready(function() { | |||||||
|         bootstrap_visualselector(); |         bootstrap_visualselector(); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     $(document).on('keydown', function(event) { |     $(document).on('keydown', function (event) { | ||||||
|         if ($("img#selector-background").is(":visible")) { |         if ($("img#selector-background").is(":visible")) { | ||||||
|             if (event.key == "Escape") { |             if (event.key == "Escape") { | ||||||
|                 state_clicked=false; |                 state_clicked = false; | ||||||
|                 ctx.clearRect(0, 0, c.width, c.height); |                 ctx.clearRect(0, 0, c.width, c.height); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     // For when the page loads |     // For when the page loads | ||||||
|     if(!window.location.hash || window.location.hash != '#visualselector') { |     if (!window.location.hash || window.location.hash != '#visualselector') { | ||||||
|         $("img#selector-background").attr('src',''); |         $("img#selector-background").attr('src', ''); | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Handle clearing button/link |     // Handle clearing button/link | ||||||
|     $('#clear-selector').on('click', function(event) { |     $('#clear-selector').on('click', function (event) { | ||||||
|         if(!state_clicked) { |         if (!state_clicked) { | ||||||
|             alert('Oops, Nothing selected!'); |             alert('Oops, Nothing selected!'); | ||||||
|         } |         } | ||||||
|         state_clicked=false; |         state_clicked = false; | ||||||
|         ctx.clearRect(0, 0, c.width, c.height); |         ctx.clearRect(0, 0, c.width, c.height); | ||||||
|  |         xctx.clearRect(0, 0, c.width, c.height); | ||||||
|  |         $("#include_filters").val(''); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |  | ||||||
|     bootstrap_visualselector(); |     bootstrap_visualselector(); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     function bootstrap_visualselector() { |     function bootstrap_visualselector() { | ||||||
|         if ( 1 ) { |         if (1) { | ||||||
|             // bootstrap it, this will trigger everything else |             // bootstrap it, this will trigger everything else | ||||||
|             $("img#selector-background").bind('load', function () { |             $("img#selector-background").bind('load', function () { | ||||||
|                 console.log("Loaded background..."); |                 console.log("Loaded background..."); | ||||||
|                c = document.getElementById("selector-canvas"); |                 c = document.getElementById("selector-canvas"); | ||||||
|                 // greyed out fill context |                 // greyed out fill context | ||||||
|                xctx = c.getContext("2d"); |                 xctx = c.getContext("2d"); | ||||||
|                 // redline highlight context |                 // redline highlight context | ||||||
|                ctx = c.getContext("2d"); |                 ctx = c.getContext("2d"); | ||||||
|                current_default_xpath =$("#css_filter").val(); |                 if ($("#include_filters").val().trim().length) { | ||||||
|                fetch_data(); |                     current_default_xpath = $("#include_filters").val().split(/\r?\n/g); | ||||||
|                $('#selector-canvas').off("mousemove mousedown"); |                 } else { | ||||||
|                // screenshot_url defined in the edit.html template |                     current_default_xpath = []; | ||||||
|  |                 } | ||||||
|  |                 fetch_data(); | ||||||
|  |                 $('#selector-canvas').off("mousemove mousedown"); | ||||||
|  |                 // screenshot_url defined in the edit.html template | ||||||
|             }).attr("src", screenshot_url); |             }).attr("src", screenshot_url); | ||||||
|         } |         } | ||||||
|  |         // Tell visualSelector that the image should update | ||||||
|  |         var s = $("img#selector-background").attr('src')+"?"+ new Date().getTime(); | ||||||
|  |         $("img#selector-background").attr('src',s) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     function fetch_data() { |     function fetch_data() { | ||||||
|       // Image is ready |         // Image is ready | ||||||
|       $('.fetching-update-notice').html("Fetching element data.."); |         $('.fetching-update-notice').html("Fetching element data.."); | ||||||
|  |  | ||||||
|       $.ajax({ |         $.ajax({ | ||||||
|         url: watch_visual_selector_data_url, |             url: watch_visual_selector_data_url, | ||||||
|         context: document.body |             context: document.body | ||||||
|       }).done(function (data) { |         }).done(function (data) { | ||||||
|         $('.fetching-update-notice').html("Rendering.."); |             $('.fetching-update-notice').html("Rendering.."); | ||||||
|         selector_data = data; |             selector_data = data; | ||||||
|         console.log("Reported browser width from backend: "+data['browser_width']); |             console.log("Reported browser width from backend: " + data['browser_width']); | ||||||
|         state_clicked=false; |             state_clicked = false; | ||||||
|         set_scale(); |             set_scale(); | ||||||
|         reflow_selector(); |             reflow_selector(); | ||||||
|         $('.fetching-update-notice').fadeOut(); |             $('.fetching-update-notice').fadeOut(); | ||||||
|       }); |         }); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     function set_scale() { |     function set_scale() { | ||||||
|  |  | ||||||
|       // some things to check if the scaling doesnt work |         // some things to check if the scaling doesnt work | ||||||
|       // - that the widths/sizes really are about the actual screen size cat elements.json |grep -o width......|sort|uniq |         // - that the widths/sizes really are about the actual screen size cat elements.json |grep -o width......|sort|uniq | ||||||
|       selector_image = $("img#selector-background")[0]; |         $("#selector-wrapper").show(); | ||||||
|       selector_image_rect = selector_image.getBoundingClientRect(); |         selector_image = $("img#selector-background")[0]; | ||||||
|  |         selector_image_rect = selector_image.getBoundingClientRect(); | ||||||
|  |  | ||||||
|       // make the canvas the same size as the image |         // make the canvas the same size as the image | ||||||
|       $('#selector-canvas').attr('height', selector_image_rect.height); |         $('#selector-canvas').attr('height', selector_image_rect.height); | ||||||
|       $('#selector-canvas').attr('width', selector_image_rect.width); |         $('#selector-canvas').attr('width', selector_image_rect.width); | ||||||
|       $('#selector-wrapper').attr('width', selector_image_rect.width); |         $('#selector-wrapper').attr('width', selector_image_rect.width); | ||||||
|       x_scale = selector_image_rect.width / selector_data['browser_width']; |         x_scale = selector_image_rect.width / selector_data['browser_width']; | ||||||
|       y_scale = selector_image_rect.height / selector_image.naturalHeight; |         y_scale = selector_image_rect.height / selector_image.naturalHeight; | ||||||
|       ctx.strokeStyle = 'rgba(255,0,0, 0.9)'; |         ctx.strokeStyle = 'rgba(255,0,0, 0.9)'; | ||||||
|       ctx.fillStyle = 'rgba(255,0,0, 0.1)'; |         ctx.fillStyle = 'rgba(255,0,0, 0.1)'; | ||||||
|       ctx.lineWidth = 3; |         ctx.lineWidth = 3; | ||||||
|       console.log("scaling set  x: "+x_scale+" by y:"+y_scale); |         console.log("scaling set  x: " + x_scale + " by y:" + y_scale); | ||||||
|       $("#selector-current-xpath").css('max-width', selector_image_rect.width); |         $("#selector-current-xpath").css('max-width', selector_image_rect.width); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     function reflow_selector() { |     function reflow_selector() { | ||||||
|         $(window).resize(function() { |         $(window).resize(function () { | ||||||
|             set_scale(); |             set_scale(); | ||||||
|             highlight_current_selected_i(); |             highlight_current_selected_i(); | ||||||
|         }); |         }); | ||||||
|       var selector_currnt_xpath_text=$("#selector-current-xpath span"); |         var selector_currnt_xpath_text = $("#selector-current-xpath span"); | ||||||
|  |  | ||||||
|       set_scale(); |         set_scale(); | ||||||
|  |  | ||||||
|       console.log(selector_data['size_pos'].length + " selectors found"); |         console.log(selector_data['size_pos'].length + " selectors found"); | ||||||
|  |  | ||||||
|       // highlight the default one if we can find it in the xPath list |         // highlight the default one if we can find it in the xPath list | ||||||
|       // or the xpath matches the default one |         // or the xpath matches the default one | ||||||
|       found = false; |         found = false; | ||||||
|       if(current_default_xpath.length) { |         if (current_default_xpath.length) { | ||||||
|           for (var i = selector_data['size_pos'].length; i!==0; i--) { |             // Find the first one that matches | ||||||
|             var sel = selector_data['size_pos'][i-1]; |             // @todo In the future paint all that match | ||||||
|             if(selector_data['size_pos'][i - 1].xpath == current_default_xpath) { |             for (const c of current_default_xpath) { | ||||||
|             console.log("highlighting "+current_default_xpath); |                 for (var i = selector_data['size_pos'].length; i !== 0; i--) { | ||||||
|               current_selected_i = i-1; |                     if (selector_data['size_pos'][i - 1].xpath === c) { | ||||||
|               highlight_current_selected_i(); |                         console.log("highlighting " + c); | ||||||
|               found = true; |                         current_selected_i = i - 1; | ||||||
|               break; |                         highlight_current_selected_i(); | ||||||
|  |                         found = true; | ||||||
|  |                         break; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 if (found) { | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             if (!found) { | ||||||
|  |                 alert("Unfortunately your existing CSS/xPath Filter was no longer found!"); | ||||||
|             } |             } | ||||||
|           } |  | ||||||
|         if(!found) { |  | ||||||
|           alert("Unfortunately your existing CSS/xPath Filter was no longer found!"); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|       $('#selector-canvas').bind('mousemove', function (e) { |  | ||||||
|         if(state_clicked) { |  | ||||||
|           return; |  | ||||||
|         } |  | ||||||
|         ctx.clearRect(0, 0, c.width, c.height); |  | ||||||
|         current_selected_i=null; |  | ||||||
|  |  | ||||||
|         // Add in offset |  | ||||||
|         if ((typeof e.offsetX === "undefined" || typeof e.offsetY === "undefined") || (e.offsetX === 0 && e.offsetY === 0)) { |  | ||||||
|           var targetOffset = $(e.target).offset(); |  | ||||||
|           e.offsetX = e.pageX - targetOffset.left; |  | ||||||
|           e.offsetY = e.pageY - targetOffset.top; |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Reverse order - the most specific one should be deeper/"laster" |  | ||||||
|         // Basically, find the most 'deepest' |  | ||||||
|         var found=0; |  | ||||||
|         ctx.fillStyle = 'rgba(205,0,0,0.35)'; |  | ||||||
|         for (var i = selector_data['size_pos'].length; i!==0; i--) { |  | ||||||
|           // draw all of them? let them choose somehow? |  | ||||||
|           var sel = selector_data['size_pos'][i-1]; |  | ||||||
|           // If we are in a bounding-box |  | ||||||
|           if (e.offsetY > sel.top * y_scale && e.offsetY < sel.top * y_scale + sel.height * y_scale |  | ||||||
|               && |  | ||||||
|               e.offsetX > sel.left * y_scale && e.offsetX < sel.left * y_scale + sel.width * y_scale |  | ||||||
|  |  | ||||||
|           ) { |         $('#selector-canvas').bind('mousemove', function (e) { | ||||||
|  |             if (state_clicked) { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |             ctx.clearRect(0, 0, c.width, c.height); | ||||||
|  |             current_selected_i = null; | ||||||
|  |  | ||||||
|             // FOUND ONE |             // Add in offset | ||||||
|  |             if ((typeof e.offsetX === "undefined" || typeof e.offsetY === "undefined") || (e.offsetX === 0 && e.offsetY === 0)) { | ||||||
|  |                 var targetOffset = $(e.target).offset(); | ||||||
|  |                 e.offsetX = e.pageX - targetOffset.left; | ||||||
|  |                 e.offsetY = e.pageY - targetOffset.top; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Reverse order - the most specific one should be deeper/"laster" | ||||||
|  |             // Basically, find the most 'deepest' | ||||||
|  |             var found = 0; | ||||||
|  |             ctx.fillStyle = 'rgba(205,0,0,0.35)'; | ||||||
|  |             for (var i = selector_data['size_pos'].length; i !== 0; i--) { | ||||||
|  |                 // draw all of them? let them choose somehow? | ||||||
|  |                 var sel = selector_data['size_pos'][i - 1]; | ||||||
|  |                 // If we are in a bounding-box | ||||||
|  |                 if (e.offsetY > sel.top * y_scale && e.offsetY < sel.top * y_scale + sel.height * y_scale | ||||||
|  |                     && | ||||||
|  |                     e.offsetX > sel.left * y_scale && e.offsetX < sel.left * y_scale + sel.width * y_scale | ||||||
|  |  | ||||||
|  |                 ) { | ||||||
|  |  | ||||||
|  |                     // FOUND ONE | ||||||
|  |                     set_current_selected_text(sel.xpath); | ||||||
|  |                     ctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||||
|  |                     ctx.fillRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||||
|  |  | ||||||
|  |                     // no need to keep digging | ||||||
|  |                     // @todo or, O to go out/up, I to go in | ||||||
|  |                     // or double click to go up/out the selector? | ||||||
|  |                     current_selected_i = i - 1; | ||||||
|  |                     found += 1; | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |         }.debounce(5)); | ||||||
|  |  | ||||||
|  |         function set_current_selected_text(s) { | ||||||
|  |             selector_currnt_xpath_text[0].innerHTML = s; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         function highlight_current_selected_i() { | ||||||
|  |             if (state_clicked) { | ||||||
|  |                 state_clicked = false; | ||||||
|  |                 xctx.clearRect(0, 0, c.width, c.height); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var sel = selector_data['size_pos'][current_selected_i]; | ||||||
|  |             if (sel[0] == '/') { | ||||||
|  |                 // @todo - not sure just checking / is right | ||||||
|  |                 $("#include_filters").val('xpath:' + sel.xpath); | ||||||
|  |             } else { | ||||||
|  |                 $("#include_filters").val(sel.xpath); | ||||||
|  |             } | ||||||
|  |             xctx.fillStyle = 'rgba(205,205,205,0.95)'; | ||||||
|  |             xctx.strokeStyle = 'rgba(225,0,0,0.9)'; | ||||||
|  |             xctx.lineWidth = 3; | ||||||
|  |             xctx.fillRect(0, 0, c.width, c.height); | ||||||
|  |             // Clear out what only should be seen (make a clear/clean spot) | ||||||
|  |             xctx.clearRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||||
|  |             xctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); | ||||||
|  |             state_clicked = true; | ||||||
|             set_current_selected_text(sel.xpath); |             set_current_selected_text(sel.xpath); | ||||||
|             ctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); |  | ||||||
|             ctx.fillRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); |  | ||||||
|  |  | ||||||
|             // no need to keep digging |  | ||||||
|             // @todo or, O to go out/up, I to go in |  | ||||||
|             // or double click to go up/out the selector? |  | ||||||
|             current_selected_i=i-1; |  | ||||||
|             found+=1; |  | ||||||
|             break; |  | ||||||
|           } |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|       }.debounce(5)); |  | ||||||
|  |  | ||||||
|       function set_current_selected_text(s) { |         $('#selector-canvas').bind('mousedown', function (e) { | ||||||
|         selector_currnt_xpath_text[0].innerHTML=s; |             highlight_current_selected_i(); | ||||||
|       } |         }); | ||||||
|  |  | ||||||
|       function highlight_current_selected_i() { |  | ||||||
|         if(state_clicked) { |  | ||||||
|           state_clicked=false; |  | ||||||
|           xctx.clearRect(0,0,c.width, c.height); |  | ||||||
|           return; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var sel = selector_data['size_pos'][current_selected_i]; |  | ||||||
|         if (sel[0] == '/') { |  | ||||||
|         // @todo - not sure just checking / is right |  | ||||||
|             $("#css_filter").val('xpath:'+sel.xpath); |  | ||||||
|         } else { |  | ||||||
|             $("#css_filter").val(sel.xpath); |  | ||||||
|         } |  | ||||||
|         xctx.fillStyle = 'rgba(205,205,205,0.95)'; |  | ||||||
|         xctx.strokeStyle = 'rgba(225,0,0,0.9)'; |  | ||||||
|         xctx.lineWidth = 3; |  | ||||||
|         xctx.fillRect(0,0,c.width, c.height); |  | ||||||
|         // Clear out what only should be seen (make a clear/clean spot) |  | ||||||
|         xctx.clearRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); |  | ||||||
|         xctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale); |  | ||||||
|         state_clicked=true; |  | ||||||
|         set_current_selected_text(sel.xpath); |  | ||||||
|  |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|       $('#selector-canvas').bind('mousedown', function (e) { |  | ||||||
|         highlight_current_selected_i(); |  | ||||||
|       }); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
| }); | }); | ||||||
| @@ -22,5 +22,18 @@ $(function () { | |||||||
|       }); |       }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |     // checkboxes - check all | ||||||
|  |     $("#check-all").click(function (e) { | ||||||
|  |         $('input[type=checkbox]').not(this).prop('checked', this.checked); | ||||||
|  |     }); | ||||||
|  |     // checkboxes - show/hide buttons | ||||||
|  |     $("input[type=checkbox]").click(function (e) { | ||||||
|  |         if ($('input[type=checkbox]:checked').length) { | ||||||
|  |             $('#checkbox-operations').slideDown(); | ||||||
|  |         } else { | ||||||
|  |             $('#checkbox-operations').slideUp(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,16 +1,40 @@ | |||||||
| $(document).ready(function() { | $(document).ready(function() { | ||||||
|     function toggle() { |     function toggle() { | ||||||
|         if ($('input[name="fetch_backend"]:checked').val() != 'html_requests') { |         if ($('input[name="fetch_backend"]:checked').val() == 'html_webdriver') { | ||||||
|             $('#requests-override-options').hide(); |             if(playwright_enabled) { | ||||||
|  |                 // playwright supports headers, so hide everything else | ||||||
|  |                 // See #664 | ||||||
|  |                 $('#requests-override-options #request-method').hide(); | ||||||
|  |                 $('#requests-override-options #request-body').hide(); | ||||||
|  |  | ||||||
|  |                 // @todo connect this one up | ||||||
|  |                 $('#ignore-status-codes-option').hide(); | ||||||
|  |             } else { | ||||||
|  |                 // selenium/webdriver doesnt support anything afaik, hide it all | ||||||
|  |                 $('#requests-override-options').hide(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |  | ||||||
|             $('#webdriver-override-options').show(); |             $('#webdriver-override-options').show(); | ||||||
|  |  | ||||||
|         } else { |         } else { | ||||||
|  |  | ||||||
|             $('#requests-override-options').show(); |             $('#requests-override-options').show(); | ||||||
|  |             $('#requests-override-options *:hidden').show(); | ||||||
|             $('#webdriver-override-options').hide(); |             $('#webdriver-override-options').hide(); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     $('input[name="fetch_backend"]').click(function (e) { |     $('input[name="fetch_backend"]').click(function (e) { | ||||||
|         toggle(); |         toggle(); | ||||||
|     }); |     }); | ||||||
|     toggle(); |     toggle(); | ||||||
|  |  | ||||||
|  |     $('#notification-setting-reset-to-default').click(function (e) { | ||||||
|  |         $('#notification_title').val(''); | ||||||
|  |         $('#notification_body').val(''); | ||||||
|  |         $('#notification_format').val('System default'); | ||||||
|  |         $('#notification_urls').val(''); | ||||||
|  |         e.preventDefault(); | ||||||
|  |     }); | ||||||
| }); | }); | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								changedetectionio/static/styles/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1 +1,3 @@ | |||||||
| node_modules | node_modules | ||||||
|  | package-lock.json | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,130 @@ | |||||||
|  | /** | ||||||
|  |  * CSS custom properties (aka variables). | ||||||
|  |  */ | ||||||
|  | :root { | ||||||
|  |   --color-white: #fff; | ||||||
|  |   --color-grey-50: #111; | ||||||
|  |   --color-grey-100: #262626; | ||||||
|  |   --color-grey-200: #333; | ||||||
|  |   --color-grey-300: #444; | ||||||
|  |   --color-grey-325: #555; | ||||||
|  |   --color-grey-350: #565d64; | ||||||
|  |   --color-grey-400: #666; | ||||||
|  |   --color-grey-500: #777; | ||||||
|  |   --color-grey-600: #999; | ||||||
|  |   --color-grey-700: #cbcbcb; | ||||||
|  |   --color-grey-750: #ddd; | ||||||
|  |   --color-grey-800: #e0e0e0; | ||||||
|  |   --color-grey-850: #eee; | ||||||
|  |   --color-grey-900: #f2f2f2; | ||||||
|  |   --color-black: #000; | ||||||
|  |   --color-background-page: var(--color-grey-100); | ||||||
|  |   --color-background-gradient-first: #5ad8f7; | ||||||
|  |   --color-background-gradient-second: #2f50af; | ||||||
|  |   --color-background-gradient-third: #9150bf; | ||||||
|  |   --color-background: var(--color-white); | ||||||
|  |   --color-text: var(--color-grey-200); | ||||||
|  |   --color-link: #1b98f8; | ||||||
|  |   --color-menu-accent: #ed5900; | ||||||
|  |   --color-background-code: var(--color-grey-850); | ||||||
|  |   --color-error: #a00; | ||||||
|  |   --color-error-input: #ffebeb; | ||||||
|  |   --color-error-list: #dd0000; | ||||||
|  |   --color-table-background: var(--color-background); | ||||||
|  |   --color-table-stripe: var(--color-grey-900); | ||||||
|  |   --color-text-tab: var(--color-white); | ||||||
|  |   --color-background-tab: rgba(255, 255, 255, 0.2); | ||||||
|  |   --color-background-tab-hover: rgba(255, 255, 255, 0.5); | ||||||
|  |   --color-text-tab-active: #222; | ||||||
|  |   --color-api-key: #0078e7; | ||||||
|  |   --color-background-button-primary: #0078e7; | ||||||
|  |   --color-background-button-green: #42dd53; | ||||||
|  |   --color-background-button-red: #dd4242; | ||||||
|  |   --color-background-button-success: rgb(28, 184, 65); | ||||||
|  |   --color-background-button-error: rgb(202, 60, 60); | ||||||
|  |   --color-text-button-error: var(--color-white); | ||||||
|  |   --color-background-button-warning: rgb(202, 60, 60); | ||||||
|  |   --color-text-button-warning: var(--color-white); | ||||||
|  |   --color-background-button-secondary: rgb(66, 184, 221); | ||||||
|  |   --color-background-button-cancel: rgb(200, 200, 200); | ||||||
|  |   --color-text-button: var(--color-white); | ||||||
|  |   --color-background-button-tag: rgb(99, 99, 99); | ||||||
|  |   --color-background-snapshot-age: #dfdfdf; | ||||||
|  |   --color-error-text-snapshot-age: var(--color-white); | ||||||
|  |   --color-error-background-snapshot-age: #ff0000; | ||||||
|  |   --color-background-button-tag-active: #9c9c9c; | ||||||
|  |   --color-text-messages: var(--color-white); | ||||||
|  |   --color-background-messages-message: rgba(255, 255, 255, .2); | ||||||
|  |   --color-background-messages-error: rgba(255, 1, 1, .5); | ||||||
|  |   --color-background-messages-notice: rgba(255, 255, 255, .5); | ||||||
|  |   --color-border-notification: #ccc; | ||||||
|  |   --color-background-checkbox-operations: rgba(0, 0, 0, 0.05); | ||||||
|  |   --color-warning: #ff3300; | ||||||
|  |   --color-border-warning: var(--color-warning); | ||||||
|  |   --color-text-legend: var(--color-white); | ||||||
|  |   --color-link-new-version: #e07171; | ||||||
|  |   --color-last-checked: #bbb; | ||||||
|  |   --color-text-footer: #444; | ||||||
|  |   --color-border-watch-table-cell: #eee; | ||||||
|  |   --color-text-watch-tag-list: #e70069; | ||||||
|  |   --color-background-new-watch-form: rgba(0, 0, 0, 0.05); | ||||||
|  |   --color-background-new-watch-input: var(--color-white); | ||||||
|  |   --color-text-new-watch-input: var(--color-text); | ||||||
|  |   --color-border-input: var(--color-grey-500); | ||||||
|  |   --color-shadow-input: var(--color-grey-400); | ||||||
|  |   --color-background-input: var(--color-white); | ||||||
|  |   --color-text-input: var(--color-text); | ||||||
|  |   --color-text-input-description: var(--color-grey-500); | ||||||
|  |   --color-text-input-placeholder: var(--color-grey-600); | ||||||
|  |   --color-background-table-thead: var(--color-grey-800); | ||||||
|  |   --color-border-table-cell: var(--color-grey-700); | ||||||
|  |   --color-text-menu-heading: var(--color-grey-350); | ||||||
|  |   --color-text-menu-link: var(--color-grey-500); | ||||||
|  |   --color-background-menu-link-hover: var(--color-grey-850); | ||||||
|  |   --color-text-menu-link-hover: var(--color-grey-300); | ||||||
|  |   --color-shadow-jump: var(--color-grey-500); | ||||||
|  |   --color-icon-github: var(--color-black); | ||||||
|  |   --color-icon-github-hover: var(--color-grey-300); } | ||||||
|  |  | ||||||
|  | html[data-darkmode="true"] { | ||||||
|  |   --color-link: #59bdfb; | ||||||
|  |   --color-text: var(--color-white); | ||||||
|  |   --color-background-gradient-first: #3f90a5; | ||||||
|  |   --color-background-gradient-second: #1e316c; | ||||||
|  |   --color-background-gradient-third: #4d2c64; | ||||||
|  |   --color-background-new-watch-input: var(--color-grey-100); | ||||||
|  |   --color-text-new-watch-input: var(--color-text); | ||||||
|  |   --color-background-table-thead: var(--color-grey-200); | ||||||
|  |   --color-table-background: var(--color-grey-300); | ||||||
|  |   --color-table-stripe: var(--color-grey-325); | ||||||
|  |   --color-background: var(--color-grey-300); | ||||||
|  |   --color-text-menu-heading: var(--color-grey-850); | ||||||
|  |   --color-text-menu-link: var(--color-grey-800); | ||||||
|  |   --color-border-table-cell: var(--color-grey-400); | ||||||
|  |   --color-text-tab-active: var(--color-text); | ||||||
|  |   --color-border-input: var(--color-grey-400); | ||||||
|  |   --color-shadow-input: var(--color-grey-50); | ||||||
|  |   --color-background-input: var(--color-grey-350); | ||||||
|  |   --color-text-input-description: var(--color-grey-600); | ||||||
|  |   --color-text-input-placeholder: var(--color-grey-600); | ||||||
|  |   --color-text-watch-tag-list: #fa3e92; | ||||||
|  |   --color-background-code: var(--color-grey-200); | ||||||
|  |   --color-background-tab: rgba(0, 0, 0, 0.2); | ||||||
|  |   --color-background-tab-hover: rgba(0, 0, 0, 0.5); | ||||||
|  |   --color-background-snapshot-age: var(--color-grey-200); | ||||||
|  |   --color-shadow-jump: var(--color-grey-200); | ||||||
|  |   --color-icon-github: var(--color-white); | ||||||
|  |   --color-icon-github-hover: var(--color-grey-700); } | ||||||
|  |   html[data-darkmode="true"] .watch-controls img { | ||||||
|  |     opacity: 0.4; } | ||||||
|  |   html[data-darkmode="true"] .icon-spread { | ||||||
|  |     filter: hue-rotate(-10deg) brightness(1.5); } | ||||||
|  |   html[data-darkmode="true"] .watch-table .title-col a[target="_blank"]::after, | ||||||
|  |   html[data-darkmode="true"] .watch-table .current-diff-url::after { | ||||||
|  |     filter: invert(0.5) hue-rotate(10deg) brightness(2); } | ||||||
|  |  | ||||||
| #diff-ui { | #diff-ui { | ||||||
|   background: #fff; |   background: var(--color-background); | ||||||
|   padding: 2em; |   padding: 2em; | ||||||
|   margin-left: 1em; |   margin-left: 1em; | ||||||
|   margin-right: 1em; |   margin-right: 1em; | ||||||
| @@ -45,6 +170,10 @@ ins { | |||||||
|     margin-left: 1em; |     margin-left: 1em; | ||||||
|     display: inline-block; |     display: inline-block; | ||||||
|     font-weight: normal; } |     font-weight: normal; } | ||||||
|  |   #settings del { | ||||||
|  |     padding: 0.5em; } | ||||||
|  |   #settings ins { | ||||||
|  |     padding: 0.5em; } | ||||||
|  |  | ||||||
| .source { | .source { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   | |||||||
| @@ -1,96 +0,0 @@ | |||||||
| #diff-ui { |  | ||||||
|  |  | ||||||
|     background: #fff; |  | ||||||
|     padding: 2em; |  | ||||||
|     margin-left: 1em; |  | ||||||
|     margin-right: 1em; |  | ||||||
|     border-radius: 5px; |  | ||||||
|     font-size: 11px; |  | ||||||
|  |  | ||||||
|     table { |  | ||||||
|         table-layout: fixed; |  | ||||||
|         width: 100%; |  | ||||||
|     } |  | ||||||
|     td { |  | ||||||
|         padding: 3px 4px; |  | ||||||
|         border: 1px solid transparent; |  | ||||||
|         vertical-align: top; |  | ||||||
|         font: 1em monospace; |  | ||||||
|         text-align: left; |  | ||||||
|     } |  | ||||||
|     pre { |  | ||||||
|             white-space: pre-wrap; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| h1 { |  | ||||||
| 	display: inline; |  | ||||||
| 	font-size: 100%; |  | ||||||
| } |  | ||||||
| del { |  | ||||||
| 	text-decoration: none; |  | ||||||
| 	color: #b30000; |  | ||||||
| 	background: #fadad7; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| ins { |  | ||||||
| 	background: #eaf2c2; |  | ||||||
| 	color: #406619; |  | ||||||
| 	text-decoration: none; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #result { |  | ||||||
| 	white-space: pre-wrap; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #settings { |  | ||||||
|     background: rgba(0,0,0,.05); |  | ||||||
|     padding: 1em; |  | ||||||
|     border-radius: 10px; |  | ||||||
|     margin-bottom: 1em; |  | ||||||
|     color: #fff; |  | ||||||
|     font-size: 80%; |  | ||||||
|     label { |  | ||||||
| 	    margin-left: 1em; |  | ||||||
| 	    display: inline-block; |  | ||||||
| 	    font-weight: normal; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .source { |  | ||||||
| 	position: absolute; |  | ||||||
| 	right: 1%; |  | ||||||
| 	top: .2em; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @-moz-document url-prefix() { |  | ||||||
| 	body { |  | ||||||
| 		height: 99%; /* Hide scroll bar in Firefox */ |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
| td#diff-col div { |  | ||||||
|     text-align: justify; |  | ||||||
|     white-space: pre-wrap; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .ignored { |  | ||||||
|     background-color: #ccc; |  | ||||||
|    /*  border: #0d91fa 1px solid; */ |  | ||||||
|     opacity: 0.7; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .triggered { |  | ||||||
|     background-color: #1b98f8; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* ignored and triggered? make it obvious error */ |  | ||||||
| .ignored.triggered { |  | ||||||
|   background-color: #ff0000; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .tab-pane-inner#screenshot { |  | ||||||
|   text-align: center; |  | ||||||
|   img { |  | ||||||
|     max-width: 99%; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
							
								
								
									
										3719
									
								
								changedetectionio/static/styles/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -4,7 +4,8 @@ | |||||||
|   "description": "", |   "description": "", | ||||||
|   "main": "index.js", |   "main": "index.js", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "build": "node-sass styles.scss -o .;node-sass diff.scss -o ." |     "watch": "node-sass -w scss -o .", | ||||||
|  |     "build": "node-sass scss -o ." | ||||||
|   }, |   }, | ||||||
|   "author": "", |   "author": "", | ||||||
|   "license": "ISC", |   "license": "ISC", | ||||||
|   | |||||||
							
								
								
									
										117
									
								
								changedetectionio/static/styles/scss/diff.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,117 @@ | |||||||
|  | @import "parts/_variables.scss"; | ||||||
|  |  | ||||||
|  | #diff-ui { | ||||||
|  |  | ||||||
|  |   background: var(--color-background); | ||||||
|  |   padding: 2em; | ||||||
|  |   margin-left: 1em; | ||||||
|  |   margin-right: 1em; | ||||||
|  |   border-radius: 5px; | ||||||
|  |   font-size: 11px; | ||||||
|  |  | ||||||
|  |   table { | ||||||
|  |     table-layout: fixed; | ||||||
|  |     width: 100%; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   td { | ||||||
|  |     padding: 3px 4px; | ||||||
|  |     border: 1px solid transparent; | ||||||
|  |     vertical-align: top; | ||||||
|  |     font: 1em monospace; | ||||||
|  |     text-align: left; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   pre { | ||||||
|  |     white-space: pre-wrap; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | h1 { | ||||||
|  |   display: inline; | ||||||
|  |   font-size: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | del { | ||||||
|  |   text-decoration: none; | ||||||
|  |   color: #b30000; | ||||||
|  |   background: #fadad7; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | ins { | ||||||
|  |   background: #eaf2c2; | ||||||
|  |   color: #406619; | ||||||
|  |   text-decoration: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #result { | ||||||
|  |   white-space: pre-wrap; | ||||||
|  |  | ||||||
|  |   .change { | ||||||
|  |     span {} | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #settings { | ||||||
|  |   background: rgba(0, 0, 0, .05); | ||||||
|  |   padding: 1em; | ||||||
|  |   border-radius: 10px; | ||||||
|  |   margin-bottom: 1em; | ||||||
|  |   color: #fff; | ||||||
|  |   font-size: 80%; | ||||||
|  |  | ||||||
|  |   label { | ||||||
|  |     margin-left: 1em; | ||||||
|  |     display: inline-block; | ||||||
|  |     font-weight: normal; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   del { | ||||||
|  |     padding: 0.5em; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   ins { | ||||||
|  |     padding: 0.5em; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .source { | ||||||
|  |   position: absolute; | ||||||
|  |   right: 1%; | ||||||
|  |   top: .2em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @-moz-document url-prefix() { | ||||||
|  |   body { | ||||||
|  |     height: 99%; | ||||||
|  |     /* Hide scroll bar in Firefox */ | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | td#diff-col div { | ||||||
|  |   text-align: justify; | ||||||
|  |   white-space: pre-wrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ignored { | ||||||
|  |   background-color: #ccc; | ||||||
|  |   /*  border: #0d91fa 1px solid; */ | ||||||
|  |   opacity: 0.7; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .triggered { | ||||||
|  |   background-color: #1b98f8; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ignored and triggered? make it obvious error */ | ||||||
|  | .ignored.triggered { | ||||||
|  |   background-color: #ff0000; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .tab-pane-inner#screenshot { | ||||||
|  |   text-align: center; | ||||||
|  |  | ||||||
|  |   img { | ||||||
|  |     max-width: 99%; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										26
									
								
								changedetectionio/static/styles/scss/parts/_arrows.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,26 @@ | |||||||
|  | .arrow { | ||||||
|  |   border: solid #1b98f8; | ||||||
|  |   border-width: 0 2px 2px 0; | ||||||
|  |   display: inline-block; | ||||||
|  |   padding: 3px; | ||||||
|  |  | ||||||
|  |   &.right { | ||||||
|  |     transform: rotate(-45deg); | ||||||
|  |     -webkit-transform: rotate(-45deg); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.left { | ||||||
|  |     transform: rotate(135deg); | ||||||
|  |     -webkit-transform: rotate(135deg); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.up, &.asc { | ||||||
|  |     transform: rotate(-135deg); | ||||||
|  |     -webkit-transform: rotate(-135deg); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.down, &.desc { | ||||||
|  |     transform: rotate(45deg); | ||||||
|  |     -webkit-transform: rotate(45deg); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,95 @@ | |||||||
|  |  | ||||||
|  | #browser_steps { | ||||||
|  |   /* convert rows to horizontal cells */ | ||||||
|  |   th { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   li { | ||||||
|  |     &:not(:first-child) { | ||||||
|  |       &:hover { | ||||||
|  |         opacity: 1.0; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     list-style: decimal; | ||||||
|  |     padding: 5px; | ||||||
|  |     .control { | ||||||
|  |       padding-left: 5px; | ||||||
|  |       padding-right: 5px; | ||||||
|  |       a { | ||||||
|  |         font-size: 70%; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     &.empty { | ||||||
|  |       padding: 0px; | ||||||
|  |       opacity: 0.35; | ||||||
|  |       .control { | ||||||
|  |         display: none; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     &:hover { | ||||||
|  |       background: #eee; | ||||||
|  |     } | ||||||
|  |     > label { | ||||||
|  |       display: none; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #browser-steps-fieldlist { | ||||||
|  |   height: 100%; | ||||||
|  |   overflow-y: scroll; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #browser-steps .flex-wrapper { | ||||||
|  |   display: flex; | ||||||
|  |   flex-flow: row; | ||||||
|  |   height: 600px; /*@todo make this dynamic */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /*  this is duplicate :( */ | ||||||
|  | #browsersteps-selector-wrapper { | ||||||
|  |   height: 100%; | ||||||
|  |   width: 100%; | ||||||
|  |   overflow-y: scroll; | ||||||
|  |   position: relative; | ||||||
|  |   //width: 100%; | ||||||
|  |   > img { | ||||||
|  |     position: absolute; | ||||||
|  |     max-width: 100%; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   > canvas { | ||||||
|  |     position: relative; | ||||||
|  |     max-width: 100%; | ||||||
|  |  | ||||||
|  |     &:hover { | ||||||
|  |       cursor: pointer; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .loader { | ||||||
|  |     position: absolute; | ||||||
|  |     left: 50%; | ||||||
|  |     top: 50%; | ||||||
|  |     transform: translate(-50%, -50%); | ||||||
|  |     margin-left: -40px; | ||||||
|  |     z-index: 100; | ||||||
|  |     max-width: 350px; | ||||||
|  |     text-align: center; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* nice tall skinny one */ | ||||||
|  |   .spinner, .spinner:after { | ||||||
|  |     width: 80px; | ||||||
|  |     height: 80px; | ||||||
|  |     font-size: 3px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   #browsersteps-click-start { | ||||||
|  |     &:hover { | ||||||
|  |       cursor: pointer; | ||||||
|  |     } | ||||||
|  |     color: var(--color-grey-400); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										44
									
								
								changedetectionio/static/styles/scss/parts/_spinners.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | |||||||
|  |  | ||||||
|  | /* spinner */ | ||||||
|  | .spinner, | ||||||
|  | .spinner:after { | ||||||
|  |   border-radius: 50%; | ||||||
|  |   width: 10px; | ||||||
|  |   height: 10px; | ||||||
|  | } | ||||||
|  | .spinner { | ||||||
|  |   margin: 0px auto; | ||||||
|  |   font-size: 3px; | ||||||
|  |   vertical-align: middle; | ||||||
|  |   display: inline-block; | ||||||
|  |   text-indent: -9999em; | ||||||
|  |   border-top: 1.1em solid rgba(38,104,237, 0.2); | ||||||
|  |   border-right: 1.1em solid rgba(38,104,237, 0.2); | ||||||
|  |   border-bottom: 1.1em solid rgba(38,104,237, 0.2); | ||||||
|  |   border-left: 1.1em solid #2668ed; | ||||||
|  |   -webkit-transform: translateZ(0); | ||||||
|  |   -ms-transform: translateZ(0); | ||||||
|  |   transform: translateZ(0); | ||||||
|  |   -webkit-animation: load8 1.1s infinite linear; | ||||||
|  |   animation: load8 1.1s infinite linear; | ||||||
|  | } | ||||||
|  | @-webkit-keyframes load8 { | ||||||
|  |   0% { | ||||||
|  |     -webkit-transform: rotate(0deg); | ||||||
|  |     transform: rotate(0deg); | ||||||
|  |   } | ||||||
|  |   100% { | ||||||
|  |     -webkit-transform: rotate(360deg); | ||||||
|  |     transform: rotate(360deg); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @keyframes load8 { | ||||||
|  |   0% { | ||||||
|  |     -webkit-transform: rotate(0deg); | ||||||
|  |     transform: rotate(0deg); | ||||||
|  |   } | ||||||
|  |   100% { | ||||||
|  |     -webkit-transform: rotate(360deg); | ||||||
|  |     transform: rotate(360deg); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										155
									
								
								changedetectionio/static/styles/scss/parts/_variables.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,155 @@ | |||||||
|  | /** | ||||||
|  |  * CSS custom properties (aka variables). | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | :root { | ||||||
|  |   --color-white: #fff; | ||||||
|  |   --color-grey-50: #111; | ||||||
|  |   --color-grey-100: #262626; | ||||||
|  |   --color-grey-200: #333; | ||||||
|  |   --color-grey-300: #444; | ||||||
|  |   --color-grey-325: #555; | ||||||
|  |   --color-grey-350: #565d64; | ||||||
|  |   --color-grey-400: #666; | ||||||
|  |   --color-grey-500: #777; | ||||||
|  |   --color-grey-600: #999; | ||||||
|  |   --color-grey-700: #cbcbcb; | ||||||
|  |   --color-grey-750: #ddd; | ||||||
|  |   --color-grey-800: #e0e0e0; | ||||||
|  |   --color-grey-850: #eee; | ||||||
|  |   --color-grey-900: #f2f2f2; | ||||||
|  |   --color-black: #000; | ||||||
|  |  | ||||||
|  |   --color-background-page: var(--color-grey-100); | ||||||
|  |   --color-background-gradient-first: #5ad8f7; | ||||||
|  |   --color-background-gradient-second: #2f50af; | ||||||
|  |   --color-background-gradient-third: #9150bf; | ||||||
|  |   --color-background: var(--color-white); | ||||||
|  |   --color-text: var(--color-grey-200); | ||||||
|  |   --color-link: #1b98f8; | ||||||
|  |   --color-menu-accent: #ed5900; | ||||||
|  |   --color-background-code: var(--color-grey-850); | ||||||
|  |   --color-error: #a00; | ||||||
|  |   --color-error-input: #ffebeb; | ||||||
|  |   --color-error-list: #dd0000; | ||||||
|  |   --color-table-background: var(--color-background); | ||||||
|  |   --color-table-stripe: var(--color-grey-900); | ||||||
|  |   --color-text-tab: var(--color-white); | ||||||
|  |   --color-background-tab: rgba(255, 255, 255, 0.2); | ||||||
|  |   --color-background-tab-hover: rgba(255, 255, 255, 0.5); | ||||||
|  |   --color-text-tab-active: #222; | ||||||
|  |   --color-api-key: #0078e7; | ||||||
|  |  | ||||||
|  |   --color-background-button-primary: #0078e7; | ||||||
|  |   --color-background-button-green: #42dd53; | ||||||
|  |   --color-background-button-red: #dd4242; | ||||||
|  |   --color-background-button-success: rgb(28, 184, 65); | ||||||
|  |   --color-background-button-error: rgb(202, 60, 60); | ||||||
|  |   --color-text-button-error: var(--color-white); | ||||||
|  |   --color-background-button-warning: rgb(202, 60, 60); | ||||||
|  |   --color-text-button-warning: var(--color-white); | ||||||
|  |   --color-background-button-secondary: rgb(66, 184, 221); | ||||||
|  |   --color-background-button-cancel: rgb(200, 200, 200); | ||||||
|  |   --color-text-button: var(--color-white); | ||||||
|  |   --color-background-button-tag: rgb(99, 99, 99); | ||||||
|  |   --color-background-snapshot-age: #dfdfdf; | ||||||
|  |   --color-error-text-snapshot-age: var(--color-white); | ||||||
|  |   --color-error-background-snapshot-age: #ff0000; | ||||||
|  |   --color-background-button-tag-active: #9c9c9c; | ||||||
|  |  | ||||||
|  |   --color-text-messages: var(--color-white); | ||||||
|  |   --color-background-messages-message: rgba(255, 255, 255, .2); | ||||||
|  |   --color-background-messages-error: rgba(255, 1, 1, .5); | ||||||
|  |   --color-background-messages-notice: rgba(255, 255, 255, .5); | ||||||
|  |   --color-border-notification: #ccc; | ||||||
|  |  | ||||||
|  |   --color-background-checkbox-operations: rgba(0, 0, 0, 0.05); | ||||||
|  |   --color-warning: #ff3300; | ||||||
|  |   --color-border-warning: var(--color-warning); | ||||||
|  |   --color-text-legend: var(--color-white); | ||||||
|  |  | ||||||
|  |   --color-link-new-version: #e07171; | ||||||
|  |   --color-last-checked: #bbb; | ||||||
|  |   --color-text-footer: #444; | ||||||
|  |   --color-border-watch-table-cell: #eee; | ||||||
|  |  | ||||||
|  |   --color-text-watch-tag-list: #e70069; | ||||||
|  |   --color-background-new-watch-form: rgba(0, 0, 0, 0.05); | ||||||
|  |   --color-background-new-watch-input: var(--color-white); | ||||||
|  |   --color-text-new-watch-input: var(--color-text); | ||||||
|  |  | ||||||
|  |   --color-border-input: var(--color-grey-500); | ||||||
|  |   --color-shadow-input: var(--color-grey-400); | ||||||
|  |   --color-background-input: var(--color-white); | ||||||
|  |   --color-text-input: var(--color-text); | ||||||
|  |   --color-text-input-description: var(--color-grey-500); | ||||||
|  |   --color-text-input-placeholder: var(--color-grey-600); | ||||||
|  |  | ||||||
|  |   --color-background-table-thead: var(--color-grey-800); | ||||||
|  |   --color-border-table-cell: var(--color-grey-700); | ||||||
|  |  | ||||||
|  |   --color-text-menu-heading: var(--color-grey-350); | ||||||
|  |   --color-text-menu-link: var(--color-grey-500); | ||||||
|  |   --color-background-menu-link-hover: var(--color-grey-850); | ||||||
|  |   --color-text-menu-link-hover: var(--color-grey-300); | ||||||
|  |  | ||||||
|  |   --color-shadow-jump: var(--color-grey-500); | ||||||
|  |   --color-icon-github: var(--color-black); | ||||||
|  |   --color-icon-github-hover: var(--color-grey-300); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | html[data-darkmode="true"] { | ||||||
|  |   --color-link: #59bdfb; | ||||||
|  |   --color-text: var(--color-white); | ||||||
|  |  | ||||||
|  |   --color-background-gradient-first: #3f90a5; | ||||||
|  |   --color-background-gradient-second: #1e316c; | ||||||
|  |   --color-background-gradient-third: #4d2c64; | ||||||
|  |  | ||||||
|  |   --color-background-new-watch-input: var(--color-grey-100); | ||||||
|  |   --color-text-new-watch-input: var(--color-text); | ||||||
|  |   --color-background-table-thead: var(--color-grey-200); | ||||||
|  |   --color-table-background: var(--color-grey-300); | ||||||
|  |   --color-table-stripe: var(--color-grey-325); | ||||||
|  |   --color-background: var(--color-grey-300); | ||||||
|  |   --color-text-menu-heading: var(--color-grey-850); | ||||||
|  |   --color-text-menu-link: var(--color-grey-800); | ||||||
|  |   --color-border-table-cell: var(--color-grey-400); | ||||||
|  |   --color-text-tab-active: var(--color-text); | ||||||
|  |  | ||||||
|  |   --color-border-input: var(--color-grey-400); | ||||||
|  |   --color-shadow-input: var(--color-grey-50); | ||||||
|  |   --color-background-input: var(--color-grey-350); | ||||||
|  |   --color-text-input-description: var(--color-grey-600); | ||||||
|  |   --color-text-input-placeholder: var(--color-grey-600); | ||||||
|  |   --color-text-watch-tag-list: #fa3e92; | ||||||
|  |  | ||||||
|  |   --color-background-code: var(--color-grey-200); | ||||||
|  |  | ||||||
|  |   --color-background-tab: rgba(0, 0, 0, 0.2); | ||||||
|  |   --color-background-tab-hover: rgba(0, 0, 0, 0.5); | ||||||
|  |  | ||||||
|  |   --color-background-snapshot-age: var(--color-grey-200); | ||||||
|  |   --color-shadow-jump: var(--color-grey-200); | ||||||
|  |   --color-icon-github: var(--color-white); | ||||||
|  |   --color-icon-github-hover: var(--color-grey-700); | ||||||
|  |  | ||||||
|  |   // Anything that can't be manipulated through variables follows. | ||||||
|  |   .watch-controls { | ||||||
|  |     img { | ||||||
|  |       opacity: 0.4; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .icon-spread { | ||||||
|  |     filter: hue-rotate(-10deg) brightness(1.5); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .watch-table { | ||||||
|  |  | ||||||
|  |     .title-col a[target="_blank"]::after, | ||||||
|  |     .current-diff-url::after { | ||||||
|  |       filter: invert(.5) hue-rotate(10deg) brightness(2); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										999
									
								
								changedetectionio/static/styles/scss/styles.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,999 @@ | |||||||
|  | /* | ||||||
|  |  * -- BASE STYLES -- | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | @import "parts/_variables"; | ||||||
|  | @import "parts/_spinners"; | ||||||
|  | @import "parts/_browser-steps"; | ||||||
|  | @import "parts/_arrows"; | ||||||
|  |  | ||||||
|  | body { | ||||||
|  |   color: var(--color-text); | ||||||
|  |   background: var(--color-background-page); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .visually-hidden { | ||||||
|  |   clip: rect(0 0 0 0); | ||||||
|  |   clip-path: inset(50%); | ||||||
|  |   height: 1px; | ||||||
|  |   overflow: hidden; | ||||||
|  |   position: absolute; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   width: 1px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .pure-table-even { | ||||||
|  |   background: var(--color-background); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Some styles from https://css-tricks.com/ */ | ||||||
|  | a { | ||||||
|  |   text-decoration: none; | ||||||
|  |   color: var(--color-link); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | a.github-link { | ||||||
|  |   color: var(--color-icon-github); | ||||||
|  |   margin: 0 1rem 0 0.5rem; | ||||||
|  |  | ||||||
|  |   svg { | ||||||
|  |     fill: currentColor; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &:hover { | ||||||
|  |     color: var(--color-icon-github-hover); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | button.toggle-theme { | ||||||
|  |   width: 4rem; | ||||||
|  |   background: transparent; | ||||||
|  |   border: none; | ||||||
|  |   cursor: pointer; | ||||||
|  |  | ||||||
|  |   color: var(--color-icon-github); | ||||||
|  |  | ||||||
|  |   &:hover { | ||||||
|  |     color: var(--color-icon-github-hover); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   svg { | ||||||
|  |     fill: currentColor; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .icon-light { | ||||||
|  |     display: block; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .icon-dark { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.dark { | ||||||
|  |     .icon-light { | ||||||
|  |       display: none; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .icon-dark { | ||||||
|  |       display: block; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .pure-menu-horizontal { | ||||||
|  |   background: var(--color-background); | ||||||
|  |   padding: 5px; | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   border-bottom: 2px solid var(--color-menu-accent); | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .pure-menu-heading { | ||||||
|  |   color: var(--color-text-menu-heading); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .pure-menu-link { | ||||||
|  |   color: var(--color-text-menu-link); | ||||||
|  |  | ||||||
|  |   &:hover { | ||||||
|  |     background-color: var(--color-background-menu-link-hover); | ||||||
|  |     color: var(--color-text-menu-link-hover); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | section.content { | ||||||
|  |   padding-top: 5em; | ||||||
|  |   padding-bottom: 1em; | ||||||
|  |   flex-direction: column; | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | code { | ||||||
|  |   background: var(--color-background-code); | ||||||
|  |   color: var(--color-text); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* table related */ | ||||||
|  | .watch-table { | ||||||
|  |   width: 100%; | ||||||
|  |   font-size: 80%; | ||||||
|  |  | ||||||
|  |   tr.unviewed { | ||||||
|  |     font-weight: bold; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .error { | ||||||
|  |     color: var(--color-error); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   td { | ||||||
|  |     white-space: nowrap; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   td.title-col { | ||||||
|  |     word-break: break-all; | ||||||
|  |     white-space: normal; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   th { | ||||||
|  |     white-space: nowrap; | ||||||
|  |  | ||||||
|  |     a { | ||||||
|  |       font-weight: normal; | ||||||
|  |  | ||||||
|  |       &.active { | ||||||
|  |         font-weight: bolder; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       &.inactive { | ||||||
|  |         .arrow { | ||||||
|  |           display: none; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .title-col a[target="_blank"]::after, | ||||||
|  |   .current-diff-url::after { | ||||||
|  |     content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==); | ||||||
|  |     margin: 0 3px 0 5px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .watch-tag-list { | ||||||
|  |   color: var(--color-text-watch-tag-list); | ||||||
|  |   white-space: nowrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .box { | ||||||
|  |   max-width: 80%; | ||||||
|  |   flex-direction: column; | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | #post-list-buttons { | ||||||
|  |   text-align: right; | ||||||
|  |   padding: 0px; | ||||||
|  |   margin: 0px; | ||||||
|  |  | ||||||
|  |   li { | ||||||
|  |     display: inline-block; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   a { | ||||||
|  |     border-top-left-radius: initial; | ||||||
|  |     border-top-right-radius: initial; | ||||||
|  |     border-bottom-left-radius: 5px; | ||||||
|  |     border-bottom-right-radius: 5px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | body:after { | ||||||
|  |   content: ""; | ||||||
|  |   background: linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body:after, | ||||||
|  | body:before { | ||||||
|  |   display: block; | ||||||
|  |   height: 650px; | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0; | ||||||
|  |   left: 0; | ||||||
|  |   width: 100%; | ||||||
|  |   z-index: -1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body::after { | ||||||
|  |   opacity: 0.91; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body::before { | ||||||
|  |   // background-image set in base.html so it works with reverse proxies etc | ||||||
|  |   content: ""; | ||||||
|  |   background-size: cover | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body:after, | ||||||
|  | body:before { | ||||||
|  |   -webkit-clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); | ||||||
|  |   clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-small { | ||||||
|  |   font-size: 85%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .fetch-error { | ||||||
|  |   padding-top: 1em; | ||||||
|  |   font-size: 80%; | ||||||
|  |   max-width: 400px; | ||||||
|  |   display: block; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .pure-button-primary, | ||||||
|  | a.pure-button-primary, | ||||||
|  | .pure-button-selected, | ||||||
|  | a.pure-button-selected { | ||||||
|  |   background-color: var(--color-background-button-primary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-secondary { | ||||||
|  |   color: var(--color-text-button); | ||||||
|  |   border-radius: 4px; | ||||||
|  |   text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-success { | ||||||
|  |   background: var(--color-background-button-success); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-tag { | ||||||
|  |   background: var(--color-background-button-tag); | ||||||
|  |   color: var(--color-text-button); | ||||||
|  |   font-size: 65%; | ||||||
|  |   border-bottom-left-radius: initial; | ||||||
|  |   border-bottom-right-radius: initial; | ||||||
|  |  | ||||||
|  |   &.active { | ||||||
|  |     background: var(--color-background-button-tag-active); | ||||||
|  |     font-weight: bold; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-error { | ||||||
|  |   background: var(--color-background-button-error); | ||||||
|  |   color: var(--color-text-button-error); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-warning { | ||||||
|  |   background: var(--color-background-button-warning); | ||||||
|  |   color: var(--color-text-button-warning); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-secondary { | ||||||
|  |   background: var(--color-background-button-secondary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-cancel { | ||||||
|  |   background: var(--color-background-button-cancel); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #save_button { | ||||||
|  |   margin-right: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .messages { | ||||||
|  |   li { | ||||||
|  |     list-style: none; | ||||||
|  |     padding: 1em; | ||||||
|  |     border-radius: 10px; | ||||||
|  |     color: var(--color-text-messages); | ||||||
|  |     font-weight: bold; | ||||||
|  |  | ||||||
|  |     &.message { | ||||||
|  |       background: var(--color-background-messages-message); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     &.error { | ||||||
|  |       background: var(--color-background-messages-error); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     &.notice { | ||||||
|  |       background: var(--color-background-messages-notice); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.with-share-link { | ||||||
|  |     >*:hover { | ||||||
|  |       cursor: pointer; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .notifications-wrapper { | ||||||
|  |   padding: 0.5rem 0 1rem 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #notification-customisation { | ||||||
|  |   border: 1px solid var(--color-border-notification); | ||||||
|  |   padding: 0.5rem; | ||||||
|  |   border-radius: 5px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #notification-error-log { | ||||||
|  |   border: 1px solid var(--color-border-notification); | ||||||
|  |   padding: 1rem; | ||||||
|  |   border-radius: 5px; | ||||||
|  |   overflow-wrap: break-word; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #token-table { | ||||||
|  |  | ||||||
|  |   &.pure-table td, | ||||||
|  |   &.pure-table th { | ||||||
|  |     font-size: 80%; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #new-watch-form { | ||||||
|  |   background: var(--color-background-new-watch-form); | ||||||
|  |   padding: 1em; | ||||||
|  |   border-radius: 10px; | ||||||
|  |   margin-bottom: 1em; | ||||||
|  |  | ||||||
|  |   input { | ||||||
|  |     display: inline-block; | ||||||
|  |     margin-bottom: 5px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   input:not(.pure-button) { | ||||||
|  |     background-color: var(--color-background-new-watch-input); | ||||||
|  |     color: var(--color-text-new-watch-input); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .label { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   legend { | ||||||
|  |     color: var(--color-text-legend); | ||||||
|  |     font-weight: bold; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   #watch-add-wrapper-zone { | ||||||
|  |     >div { | ||||||
|  |       display: inline-block; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @media only screen and (max-width: 760px) { | ||||||
|  |       #url { | ||||||
|  |         width: 100%; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | #diff-col { | ||||||
|  |   padding-left: 40px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #diff-jump { | ||||||
|  |   position: fixed; | ||||||
|  |   left: 0px; | ||||||
|  |   top: 120px; | ||||||
|  |   background: var(--color-background); | ||||||
|  |   padding: 10px; | ||||||
|  |   border-top-right-radius: 5px; | ||||||
|  |   border-bottom-right-radius: 5px; | ||||||
|  |   box-shadow: 1px 1px 4px var(--color-shadow-jump); | ||||||
|  |  | ||||||
|  |   a { | ||||||
|  |     color: var(--color-link); | ||||||
|  |     cursor: pointer; | ||||||
|  |     -moz-user-select: none; | ||||||
|  |     -webkit-user-select: none; | ||||||
|  |     -ms-user-select: none; | ||||||
|  |     user-select: none; | ||||||
|  |     -o-user-select: none; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | footer { | ||||||
|  |   padding: 10px; | ||||||
|  |   background: var(--color-background); | ||||||
|  |   color: var(--color-text-footer); | ||||||
|  |   text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #feed-icon { | ||||||
|  |   vertical-align: middle; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #top-right-menu { | ||||||
|  |   // Just let flex overflow the x axis for now | ||||||
|  |   /* | ||||||
|  |       position: absolute; | ||||||
|  |       right: 0px; | ||||||
|  |       background: linear-gradient(to right, #fff0, #fff 10%); | ||||||
|  |       padding-left: 20px; | ||||||
|  |       padding-right: 10px; | ||||||
|  |       */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .sticky-tab { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 60px; | ||||||
|  |   font-size: 65%; | ||||||
|  |   background: var(--color-background); | ||||||
|  |   padding: 10px; | ||||||
|  |  | ||||||
|  |   &#left-sticky { | ||||||
|  |     left: 0px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &#right-sticky { | ||||||
|  |     right: 0px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &#hosted-sticky { | ||||||
|  |     right: 0px; | ||||||
|  |     top: 100px; | ||||||
|  |     font-weight: bold; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #new-version-text a { | ||||||
|  |   color: var(--color-link-new-version); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .watch-controls { | ||||||
|  |   color: #f8321b; | ||||||
|  |  | ||||||
|  |   .state-on { | ||||||
|  |     img { | ||||||
|  |       opacity: 0.8; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* default */ | ||||||
|  |   img { | ||||||
|  |     opacity: 0.2; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   img { | ||||||
|  |     &:hover { | ||||||
|  |       transition: opacity 0.3s; | ||||||
|  |       opacity: 0.8; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .monospaced-textarea { | ||||||
|  |   textarea { | ||||||
|  |     width: 100%; | ||||||
|  |     font-family: monospace; | ||||||
|  |     white-space: pre; | ||||||
|  |     overflow-wrap: normal; | ||||||
|  |     // No scrollbars until needed. | ||||||
|  |     overflow-x: auto; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .pure-form { | ||||||
|  |   fieldset { | ||||||
|  |     padding-top: 0px; | ||||||
|  |  | ||||||
|  |     ul { | ||||||
|  |       padding-bottom: 0px; | ||||||
|  |       margin-bottom: 0px; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .pure-control-group, | ||||||
|  |   .pure-group, | ||||||
|  |   .pure-controls { | ||||||
|  |     padding-bottom: 1em; | ||||||
|  |  | ||||||
|  |     div { | ||||||
|  |       margin: 0px; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .checkbox { | ||||||
|  |       >* { | ||||||
|  |         display: inline; | ||||||
|  |         vertical-align: middle; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       >label { | ||||||
|  |         padding-left: 5px; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     legend { | ||||||
|  |       color: var(--color-text-legend); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* The input fields with errors */ | ||||||
|  |   .error { | ||||||
|  |     input { | ||||||
|  |       background-color: var(--color-error-input); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* The list of errors */ | ||||||
|  |   ul.errors { | ||||||
|  |     padding: .5em .6em; | ||||||
|  |     border: 1px solid var(--color-error-list); | ||||||
|  |     border-radius: 4px; | ||||||
|  |     vertical-align: middle; | ||||||
|  |     -webkit-box-sizing: border-box; | ||||||
|  |     box-sizing: border-box; | ||||||
|  |  | ||||||
|  |     li { | ||||||
|  |       margin-left: 1em; | ||||||
|  |       color: var(--color-error-list); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   label { | ||||||
|  |     font-weight: bold; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   textarea { | ||||||
|  |     width: 100%; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .inline-radio { | ||||||
|  |     ul { | ||||||
|  |       margin: 0px; | ||||||
|  |       list-style: none; | ||||||
|  |  | ||||||
|  |       li { | ||||||
|  |         >* { | ||||||
|  |           display: inline-block; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @media only screen and (max-width: 760px), | ||||||
|  | (min-device-width: 768px) and (max-device-width: 1024px) { | ||||||
|  |   .box { | ||||||
|  |     max-width: 95% | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .edit-form { | ||||||
|  |     padding: 0.5em; | ||||||
|  |     margin: 0; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   #nav-menu { | ||||||
|  |     overflow-x: scroll; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @media only screen and (max-width: 760px), | ||||||
|  | (min-device-width: 768px) and (max-device-width: 800px) { | ||||||
|  |  | ||||||
|  |   div.sticky-tab#hosted-sticky { | ||||||
|  |     top: 60px; | ||||||
|  |     left: 0px; | ||||||
|  |     right: auto; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   section.content { | ||||||
|  |     padding-top: 110px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Make the tabs easier to hit, they will be all nice and horizontal | ||||||
|  |   div.tabs.collapsable ul li { | ||||||
|  |     display: block; | ||||||
|  |     border-radius: 0px; | ||||||
|  |     margin-right: 0px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   input[type='text'] { | ||||||
|  |     width: 100%; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* | ||||||
|  |   Max width before this PARTICULAR table gets nasty | ||||||
|  |   This query will take effect for any screen smaller than 760px | ||||||
|  |   and also iPads specifically. | ||||||
|  |   */ | ||||||
|  |   .watch-table { | ||||||
|  |  | ||||||
|  |     /* Force table to not be like tables anymore */ | ||||||
|  |     thead, | ||||||
|  |     tbody, | ||||||
|  |     th, | ||||||
|  |     td, | ||||||
|  |     tr { | ||||||
|  |       display: block; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .last-checked { | ||||||
|  |       >span { | ||||||
|  |         vertical-align: middle; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .last-checked::before { | ||||||
|  |       color: var(--color-last-checked); | ||||||
|  |       content: "Last Checked "; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .last-changed::before { | ||||||
|  |       color: var(--color-last-checked); | ||||||
|  |       content: "Last Changed "; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /* Force table to not be like tables anymore */ | ||||||
|  |     td.inline { | ||||||
|  |       display: inline-block; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /* Hide table headers (but not display: none;, for accessibility) */ | ||||||
|  |     thead tr { | ||||||
|  |       position: absolute; | ||||||
|  |       top: -9999px; | ||||||
|  |       left: -9999px; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .pure-table td, | ||||||
|  |     .pure-table th { | ||||||
|  |       border: none; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     td { | ||||||
|  |       /* Behave  like a "row" */ | ||||||
|  |       border: none; | ||||||
|  |       border-bottom: 1px solid var(--color-border-watch-table-cell); | ||||||
|  |       vertical-align: middle; | ||||||
|  |  | ||||||
|  |       &:before { | ||||||
|  |         /* Top/left values mimic padding */ | ||||||
|  |         top: 6px; | ||||||
|  |         left: 6px; | ||||||
|  |         width: 45%; | ||||||
|  |         padding-right: 10px; | ||||||
|  |         white-space: nowrap; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     &.pure-table-striped { | ||||||
|  |       tr { | ||||||
|  |         background-color: var(--color-table-background); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       tr:nth-child(2n-1) { | ||||||
|  |         background-color: var(--color-table-stripe); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       tr:nth-child(2n-1) td { | ||||||
|  |         background-color: inherit; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .pure-table { | ||||||
|  |   border-color: var(--color-border-table-cell); | ||||||
|  |  | ||||||
|  |   thead { | ||||||
|  |     background-color: var(--color-background-table-thead); | ||||||
|  |     color: var(--color-text); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   td, | ||||||
|  |   th { | ||||||
|  |     border-left-color: var(--color-border-table-cell); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .pure-table-striped { | ||||||
|  |   tr:nth-child(2n-1) { | ||||||
|  |     td { | ||||||
|  |       background-color: var(--color-table-stripe); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .pure-form input[type=color], | ||||||
|  | .pure-form input[type=date], | ||||||
|  | .pure-form input[type=datetime-local], | ||||||
|  | .pure-form input[type=datetime], | ||||||
|  | .pure-form input[type=email], | ||||||
|  | .pure-form input[type=month], | ||||||
|  | .pure-form input[type=number], | ||||||
|  | .pure-form input[type=password], | ||||||
|  | .pure-form input[type=search], | ||||||
|  | .pure-form input[type=tel], | ||||||
|  | .pure-form input[type=text], | ||||||
|  | .pure-form input[type=time], | ||||||
|  | .pure-form input[type=url], | ||||||
|  | .pure-form input[type=week], | ||||||
|  | .pure-form select, | ||||||
|  | .pure-form textarea { | ||||||
|  |   border: var(--color-border-input); | ||||||
|  |   box-shadow: inset 0 1px 3px var(--color-shadow-input); | ||||||
|  |   background-color: var(--color-background-input); | ||||||
|  |   color: var(--color-text-input); | ||||||
|  |  | ||||||
|  |   &:active { | ||||||
|  |     background-color: var(--color-background-input); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | input::placeholder, | ||||||
|  | textarea::placeholder { | ||||||
|  |   color: var(--color-text-input-placeholder); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** Desktop vs mobile input field strategy | ||||||
|  | - We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out | ||||||
|  | - Rely always on width in CSS | ||||||
|  | */ | ||||||
|  | @media only screen and (min-width: 761px) { | ||||||
|  |  | ||||||
|  |   /* m-d is medium-desktop */ | ||||||
|  |   .m-d { | ||||||
|  |     min-width: 80%; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .tabs { | ||||||
|  |   ul { | ||||||
|  |     margin: 0px; | ||||||
|  |     padding: 0px; | ||||||
|  |     display: block; | ||||||
|  |  | ||||||
|  |     li { | ||||||
|  |       margin-right: 3px; | ||||||
|  |       display: inline-block; | ||||||
|  |       color: var(--color-text-tab); | ||||||
|  |       border-top-left-radius: 5px; | ||||||
|  |       border-top-right-radius: 5px; | ||||||
|  |       background-color: var(--color-background-tab); | ||||||
|  |  | ||||||
|  |       &:not(.active) { | ||||||
|  |         &:hover { | ||||||
|  |           background-color: var(--color-background-tab-hover); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       &.active, | ||||||
|  |       :target { | ||||||
|  |         background-color: var(--color-background); | ||||||
|  |  | ||||||
|  |         a { | ||||||
|  |           color: var(--color-text-tab-active); | ||||||
|  |           font-weight: bold; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       a { | ||||||
|  |         display: block; | ||||||
|  |         padding: 0.8em; | ||||||
|  |         color: var(--color-text-tab); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | $form-edge-padding: 20px; | ||||||
|  |  | ||||||
|  | .pure-form-stacked { | ||||||
|  |   >div:first-child { | ||||||
|  |     display: block; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-form { | ||||||
|  |   .inner { | ||||||
|  |     background: var(--color-background); | ||||||
|  |     ; | ||||||
|  |     padding: $form-edge-padding; | ||||||
|  |     border-radius: 5px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .tab-pane-inner { | ||||||
|  |   &:not(:target) { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &:target { | ||||||
|  |     display: block; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // doesnt need padding because theres another row of buttons/activity | ||||||
|  |   padding: 0px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .beta-logo { | ||||||
|  |   height: 50px; | ||||||
|  |   // looks better when it's hanging off a little | ||||||
|  |   right: -3px; | ||||||
|  |   top: -3px; | ||||||
|  |   position: absolute; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #selector-header { | ||||||
|  |   padding-bottom: 1em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body.full-width { | ||||||
|  |   .edit-form { | ||||||
|  |     width: 95%; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .edit-form { | ||||||
|  |   min-width: 70%; | ||||||
|  |   /* so it cant overflow */ | ||||||
|  |   max-width: 95%; | ||||||
|  |  | ||||||
|  |   .box-wrap { | ||||||
|  |     position: relative; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .inner { | ||||||
|  |     background: var(--color-background); | ||||||
|  |     padding: $form-edge-padding; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   #actions { | ||||||
|  |     display: block; | ||||||
|  |     background: var(--color-background); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .pure-form-message-inline { | ||||||
|  |     padding-left: 0; | ||||||
|  |     color: var(--color-text-input-description); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | ul { | ||||||
|  |   padding-left: 1em; | ||||||
|  |   padding-top: 0px; | ||||||
|  |   margin-top: 4px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .time-check-widget { | ||||||
|  |   tr { | ||||||
|  |     display: inline; | ||||||
|  |  | ||||||
|  |     input[type="number"] { | ||||||
|  |       width: 5em; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #selector-wrapper { | ||||||
|  |   height: 100%; | ||||||
|  |   overflow-y: scroll; | ||||||
|  |   position: relative; | ||||||
|  |  | ||||||
|  |   //width: 100%; | ||||||
|  |   >img { | ||||||
|  |     position: absolute; | ||||||
|  |     z-index: 4; | ||||||
|  |     max-width: 100%; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   >canvas { | ||||||
|  |     position: relative; | ||||||
|  |     z-index: 5; | ||||||
|  |     max-width: 100%; | ||||||
|  |  | ||||||
|  |     &:hover { | ||||||
|  |       cursor: pointer; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #selector-current-xpath { | ||||||
|  |   font-size: 80%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #webdriver-override-options { | ||||||
|  |   input[type="number"] { | ||||||
|  |     width: 5em; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #api-key { | ||||||
|  |   &:hover { | ||||||
|  |     cursor: pointer; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #api-key-copy { | ||||||
|  |   color: var(--color-api-key); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-green { | ||||||
|  |   background-color: var(--color-background-button-green); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-red { | ||||||
|  |   background-color: var(--color-background-button-red); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .noselect { | ||||||
|  |   -webkit-touch-callout: none; | ||||||
|  |   /* iOS Safari */ | ||||||
|  |   -webkit-user-select: none; | ||||||
|  |   /* Safari */ | ||||||
|  |   -moz-user-select: none; | ||||||
|  |   /* Old versions of Firefox */ | ||||||
|  |   -ms-user-select: none; | ||||||
|  |   /* Internet Explorer/Edge */ | ||||||
|  |   user-select: none; | ||||||
|  |   /* Non-prefixed version, currently | ||||||
|  |     supported by Chrome, Edge, Opera and Firefox */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .snapshot-age { | ||||||
|  |   padding: 4px; | ||||||
|  |   margin: 0.5rem 0; | ||||||
|  |   background-color: var(--color-background-snapshot-age); | ||||||
|  |   border-radius: 3px; | ||||||
|  |   font-weight: bold; | ||||||
|  |   margin-bottom: 4px; | ||||||
|  |  | ||||||
|  |   &.error { | ||||||
|  |     background-color: var(--color-error-background-snapshot-age); | ||||||
|  |     color: var(--color-error-text-snapshot-age); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #checkbox-operations { | ||||||
|  |   background: var(--color-background-checkbox-operations); | ||||||
|  |   padding: 1em; | ||||||
|  |   border-radius: 10px; | ||||||
|  |   margin-bottom: 1em; | ||||||
|  |   display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .checkbox-uuid { | ||||||
|  |   >* { | ||||||
|  |     vertical-align: middle; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .inline-warning { | ||||||
|  |   >span { | ||||||
|  |     display: inline-block; | ||||||
|  |     vertical-align: middle; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   img.inline-warning-icon { | ||||||
|  |     display: inline; | ||||||
|  |     height: 26px; | ||||||
|  |     vertical-align: middle; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   border: 1px solid var(--color-border-warning); | ||||||
|  |   padding: 0.5rem; | ||||||
|  |   border-radius: 5px; | ||||||
|  |   color: var(--color-warning); | ||||||
|  | } | ||||||
| @@ -1,34 +1,320 @@ | |||||||
| /* | /* | ||||||
|  * -- BASE STYLES -- |  * -- BASE STYLES -- | ||||||
|  * Most of these are inherited from Base, but I want to change a few. |  | ||||||
|  * nvm use v14.18.1 |  | ||||||
|  * npm install |  | ||||||
|  * npm run build |  | ||||||
|  * or npm run watch |  | ||||||
|  */ |  */ | ||||||
|  | /** | ||||||
|  |  * CSS custom properties (aka variables). | ||||||
|  |  */ | ||||||
|  | :root { | ||||||
|  |   --color-white: #fff; | ||||||
|  |   --color-grey-50: #111; | ||||||
|  |   --color-grey-100: #262626; | ||||||
|  |   --color-grey-200: #333; | ||||||
|  |   --color-grey-300: #444; | ||||||
|  |   --color-grey-325: #555; | ||||||
|  |   --color-grey-350: #565d64; | ||||||
|  |   --color-grey-400: #666; | ||||||
|  |   --color-grey-500: #777; | ||||||
|  |   --color-grey-600: #999; | ||||||
|  |   --color-grey-700: #cbcbcb; | ||||||
|  |   --color-grey-750: #ddd; | ||||||
|  |   --color-grey-800: #e0e0e0; | ||||||
|  |   --color-grey-850: #eee; | ||||||
|  |   --color-grey-900: #f2f2f2; | ||||||
|  |   --color-black: #000; | ||||||
|  |   --color-background-page: var(--color-grey-100); | ||||||
|  |   --color-background-gradient-first: #5ad8f7; | ||||||
|  |   --color-background-gradient-second: #2f50af; | ||||||
|  |   --color-background-gradient-third: #9150bf; | ||||||
|  |   --color-background: var(--color-white); | ||||||
|  |   --color-text: var(--color-grey-200); | ||||||
|  |   --color-link: #1b98f8; | ||||||
|  |   --color-menu-accent: #ed5900; | ||||||
|  |   --color-background-code: var(--color-grey-850); | ||||||
|  |   --color-error: #a00; | ||||||
|  |   --color-error-input: #ffebeb; | ||||||
|  |   --color-error-list: #dd0000; | ||||||
|  |   --color-table-background: var(--color-background); | ||||||
|  |   --color-table-stripe: var(--color-grey-900); | ||||||
|  |   --color-text-tab: var(--color-white); | ||||||
|  |   --color-background-tab: rgba(255, 255, 255, 0.2); | ||||||
|  |   --color-background-tab-hover: rgba(255, 255, 255, 0.5); | ||||||
|  |   --color-text-tab-active: #222; | ||||||
|  |   --color-api-key: #0078e7; | ||||||
|  |   --color-background-button-primary: #0078e7; | ||||||
|  |   --color-background-button-green: #42dd53; | ||||||
|  |   --color-background-button-red: #dd4242; | ||||||
|  |   --color-background-button-success: rgb(28, 184, 65); | ||||||
|  |   --color-background-button-error: rgb(202, 60, 60); | ||||||
|  |   --color-text-button-error: var(--color-white); | ||||||
|  |   --color-background-button-warning: rgb(202, 60, 60); | ||||||
|  |   --color-text-button-warning: var(--color-white); | ||||||
|  |   --color-background-button-secondary: rgb(66, 184, 221); | ||||||
|  |   --color-background-button-cancel: rgb(200, 200, 200); | ||||||
|  |   --color-text-button: var(--color-white); | ||||||
|  |   --color-background-button-tag: rgb(99, 99, 99); | ||||||
|  |   --color-background-snapshot-age: #dfdfdf; | ||||||
|  |   --color-error-text-snapshot-age: var(--color-white); | ||||||
|  |   --color-error-background-snapshot-age: #ff0000; | ||||||
|  |   --color-background-button-tag-active: #9c9c9c; | ||||||
|  |   --color-text-messages: var(--color-white); | ||||||
|  |   --color-background-messages-message: rgba(255, 255, 255, .2); | ||||||
|  |   --color-background-messages-error: rgba(255, 1, 1, .5); | ||||||
|  |   --color-background-messages-notice: rgba(255, 255, 255, .5); | ||||||
|  |   --color-border-notification: #ccc; | ||||||
|  |   --color-background-checkbox-operations: rgba(0, 0, 0, 0.05); | ||||||
|  |   --color-warning: #ff3300; | ||||||
|  |   --color-border-warning: var(--color-warning); | ||||||
|  |   --color-text-legend: var(--color-white); | ||||||
|  |   --color-link-new-version: #e07171; | ||||||
|  |   --color-last-checked: #bbb; | ||||||
|  |   --color-text-footer: #444; | ||||||
|  |   --color-border-watch-table-cell: #eee; | ||||||
|  |   --color-text-watch-tag-list: #e70069; | ||||||
|  |   --color-background-new-watch-form: rgba(0, 0, 0, 0.05); | ||||||
|  |   --color-background-new-watch-input: var(--color-white); | ||||||
|  |   --color-text-new-watch-input: var(--color-text); | ||||||
|  |   --color-border-input: var(--color-grey-500); | ||||||
|  |   --color-shadow-input: var(--color-grey-400); | ||||||
|  |   --color-background-input: var(--color-white); | ||||||
|  |   --color-text-input: var(--color-text); | ||||||
|  |   --color-text-input-description: var(--color-grey-500); | ||||||
|  |   --color-text-input-placeholder: var(--color-grey-600); | ||||||
|  |   --color-background-table-thead: var(--color-grey-800); | ||||||
|  |   --color-border-table-cell: var(--color-grey-700); | ||||||
|  |   --color-text-menu-heading: var(--color-grey-350); | ||||||
|  |   --color-text-menu-link: var(--color-grey-500); | ||||||
|  |   --color-background-menu-link-hover: var(--color-grey-850); | ||||||
|  |   --color-text-menu-link-hover: var(--color-grey-300); | ||||||
|  |   --color-shadow-jump: var(--color-grey-500); | ||||||
|  |   --color-icon-github: var(--color-black); | ||||||
|  |   --color-icon-github-hover: var(--color-grey-300); } | ||||||
|  |  | ||||||
|  | html[data-darkmode="true"] { | ||||||
|  |   --color-link: #59bdfb; | ||||||
|  |   --color-text: var(--color-white); | ||||||
|  |   --color-background-gradient-first: #3f90a5; | ||||||
|  |   --color-background-gradient-second: #1e316c; | ||||||
|  |   --color-background-gradient-third: #4d2c64; | ||||||
|  |   --color-background-new-watch-input: var(--color-grey-100); | ||||||
|  |   --color-text-new-watch-input: var(--color-text); | ||||||
|  |   --color-background-table-thead: var(--color-grey-200); | ||||||
|  |   --color-table-background: var(--color-grey-300); | ||||||
|  |   --color-table-stripe: var(--color-grey-325); | ||||||
|  |   --color-background: var(--color-grey-300); | ||||||
|  |   --color-text-menu-heading: var(--color-grey-850); | ||||||
|  |   --color-text-menu-link: var(--color-grey-800); | ||||||
|  |   --color-border-table-cell: var(--color-grey-400); | ||||||
|  |   --color-text-tab-active: var(--color-text); | ||||||
|  |   --color-border-input: var(--color-grey-400); | ||||||
|  |   --color-shadow-input: var(--color-grey-50); | ||||||
|  |   --color-background-input: var(--color-grey-350); | ||||||
|  |   --color-text-input-description: var(--color-grey-600); | ||||||
|  |   --color-text-input-placeholder: var(--color-grey-600); | ||||||
|  |   --color-text-watch-tag-list: #fa3e92; | ||||||
|  |   --color-background-code: var(--color-grey-200); | ||||||
|  |   --color-background-tab: rgba(0, 0, 0, 0.2); | ||||||
|  |   --color-background-tab-hover: rgba(0, 0, 0, 0.5); | ||||||
|  |   --color-background-snapshot-age: var(--color-grey-200); | ||||||
|  |   --color-shadow-jump: var(--color-grey-200); | ||||||
|  |   --color-icon-github: var(--color-white); | ||||||
|  |   --color-icon-github-hover: var(--color-grey-700); } | ||||||
|  |   html[data-darkmode="true"] .watch-controls img { | ||||||
|  |     opacity: 0.4; } | ||||||
|  |   html[data-darkmode="true"] .icon-spread { | ||||||
|  |     filter: hue-rotate(-10deg) brightness(1.5); } | ||||||
|  |   html[data-darkmode="true"] .watch-table .title-col a[target="_blank"]::after, | ||||||
|  |   html[data-darkmode="true"] .watch-table .current-diff-url::after { | ||||||
|  |     filter: invert(0.5) hue-rotate(10deg) brightness(2); } | ||||||
|  |  | ||||||
|  | /* spinner */ | ||||||
|  | .spinner, | ||||||
|  | .spinner:after { | ||||||
|  |   border-radius: 50%; | ||||||
|  |   width: 10px; | ||||||
|  |   height: 10px; } | ||||||
|  |  | ||||||
|  | .spinner { | ||||||
|  |   margin: 0px auto; | ||||||
|  |   font-size: 3px; | ||||||
|  |   vertical-align: middle; | ||||||
|  |   display: inline-block; | ||||||
|  |   text-indent: -9999em; | ||||||
|  |   border-top: 1.1em solid rgba(38, 104, 237, 0.2); | ||||||
|  |   border-right: 1.1em solid rgba(38, 104, 237, 0.2); | ||||||
|  |   border-bottom: 1.1em solid rgba(38, 104, 237, 0.2); | ||||||
|  |   border-left: 1.1em solid #2668ed; | ||||||
|  |   -webkit-transform: translateZ(0); | ||||||
|  |   -ms-transform: translateZ(0); | ||||||
|  |   transform: translateZ(0); | ||||||
|  |   -webkit-animation: load8 1.1s infinite linear; | ||||||
|  |   animation: load8 1.1s infinite linear; } | ||||||
|  |  | ||||||
|  | @-webkit-keyframes load8 { | ||||||
|  |   0% { | ||||||
|  |     -webkit-transform: rotate(0deg); | ||||||
|  |     transform: rotate(0deg); } | ||||||
|  |   100% { | ||||||
|  |     -webkit-transform: rotate(360deg); | ||||||
|  |     transform: rotate(360deg); } } | ||||||
|  |  | ||||||
|  | @keyframes load8 { | ||||||
|  |   0% { | ||||||
|  |     -webkit-transform: rotate(0deg); | ||||||
|  |     transform: rotate(0deg); } | ||||||
|  |   100% { | ||||||
|  |     -webkit-transform: rotate(360deg); | ||||||
|  |     transform: rotate(360deg); } } | ||||||
|  |  | ||||||
|  | #browser_steps { | ||||||
|  |   /* convert rows to horizontal cells */ } | ||||||
|  |   #browser_steps th { | ||||||
|  |     display: none; } | ||||||
|  |   #browser_steps li { | ||||||
|  |     list-style: decimal; | ||||||
|  |     padding: 5px; } | ||||||
|  |     #browser_steps li:not(:first-child):hover { | ||||||
|  |       opacity: 1.0; } | ||||||
|  |     #browser_steps li .control { | ||||||
|  |       padding-left: 5px; | ||||||
|  |       padding-right: 5px; } | ||||||
|  |       #browser_steps li .control a { | ||||||
|  |         font-size: 70%; } | ||||||
|  |     #browser_steps li.empty { | ||||||
|  |       padding: 0px; | ||||||
|  |       opacity: 0.35; } | ||||||
|  |       #browser_steps li.empty .control { | ||||||
|  |         display: none; } | ||||||
|  |     #browser_steps li:hover { | ||||||
|  |       background: #eee; } | ||||||
|  |     #browser_steps li > label { | ||||||
|  |       display: none; } | ||||||
|  |  | ||||||
|  | #browser-steps-fieldlist { | ||||||
|  |   height: 100%; | ||||||
|  |   overflow-y: scroll; } | ||||||
|  |  | ||||||
|  | #browser-steps .flex-wrapper { | ||||||
|  |   display: flex; | ||||||
|  |   flex-flow: row; | ||||||
|  |   height: 600px; | ||||||
|  |   /*@todo make this dynamic */ } | ||||||
|  |  | ||||||
|  | /*  this is duplicate :( */ | ||||||
|  | #browsersteps-selector-wrapper { | ||||||
|  |   height: 100%; | ||||||
|  |   width: 100%; | ||||||
|  |   overflow-y: scroll; | ||||||
|  |   position: relative; | ||||||
|  |   /* nice tall skinny one */ } | ||||||
|  |   #browsersteps-selector-wrapper > img { | ||||||
|  |     position: absolute; | ||||||
|  |     max-width: 100%; } | ||||||
|  |   #browsersteps-selector-wrapper > canvas { | ||||||
|  |     position: relative; | ||||||
|  |     max-width: 100%; } | ||||||
|  |     #browsersteps-selector-wrapper > canvas:hover { | ||||||
|  |       cursor: pointer; } | ||||||
|  |   #browsersteps-selector-wrapper .loader { | ||||||
|  |     position: absolute; | ||||||
|  |     left: 50%; | ||||||
|  |     top: 50%; | ||||||
|  |     transform: translate(-50%, -50%); | ||||||
|  |     margin-left: -40px; | ||||||
|  |     z-index: 100; | ||||||
|  |     max-width: 350px; | ||||||
|  |     text-align: center; } | ||||||
|  |   #browsersteps-selector-wrapper .spinner, #browsersteps-selector-wrapper .spinner:after { | ||||||
|  |     width: 80px; | ||||||
|  |     height: 80px; | ||||||
|  |     font-size: 3px; } | ||||||
|  |   #browsersteps-selector-wrapper #browsersteps-click-start { | ||||||
|  |     color: var(--color-grey-400); } | ||||||
|  |     #browsersteps-selector-wrapper #browsersteps-click-start:hover { | ||||||
|  |       cursor: pointer; } | ||||||
|  |  | ||||||
|  | .arrow { | ||||||
|  |   border: solid #1b98f8; | ||||||
|  |   border-width: 0 2px 2px 0; | ||||||
|  |   display: inline-block; | ||||||
|  |   padding: 3px; } | ||||||
|  |   .arrow.right { | ||||||
|  |     transform: rotate(-45deg); | ||||||
|  |     -webkit-transform: rotate(-45deg); } | ||||||
|  |   .arrow.left { | ||||||
|  |     transform: rotate(135deg); | ||||||
|  |     -webkit-transform: rotate(135deg); } | ||||||
|  |   .arrow.up, .arrow.asc { | ||||||
|  |     transform: rotate(-135deg); | ||||||
|  |     -webkit-transform: rotate(-135deg); } | ||||||
|  |   .arrow.down, .arrow.desc { | ||||||
|  |     transform: rotate(45deg); | ||||||
|  |     -webkit-transform: rotate(45deg); } | ||||||
|  |  | ||||||
| body { | body { | ||||||
|   color: #333; |   color: var(--color-text); | ||||||
|   background: #262626; } |   background: var(--color-background-page); } | ||||||
|  |  | ||||||
|  | .visually-hidden { | ||||||
|  |   clip: rect(0 0 0 0); | ||||||
|  |   clip-path: inset(50%); | ||||||
|  |   height: 1px; | ||||||
|  |   overflow: hidden; | ||||||
|  |   position: absolute; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   width: 1px; } | ||||||
|  |  | ||||||
| .pure-table-even { | .pure-table-even { | ||||||
|   background: #fff; } |   background: var(--color-background); } | ||||||
|  |  | ||||||
| /* Some styles from https://css-tricks.com/ */ | /* Some styles from https://css-tricks.com/ */ | ||||||
| a { | a { | ||||||
|   text-decoration: none; |   text-decoration: none; | ||||||
|   color: #1b98f8; } |   color: var(--color-link); } | ||||||
|  |  | ||||||
| a.github-link { | a.github-link { | ||||||
|   color: #fff; } |   color: var(--color-icon-github); | ||||||
|  |   margin: 0 1rem 0 0.5rem; } | ||||||
|  |   a.github-link svg { | ||||||
|  |     fill: currentColor; } | ||||||
|  |   a.github-link:hover { | ||||||
|  |     color: var(--color-icon-github-hover); } | ||||||
|  |  | ||||||
|  | button.toggle-theme { | ||||||
|  |   width: 4rem; | ||||||
|  |   background: transparent; | ||||||
|  |   border: none; | ||||||
|  |   cursor: pointer; | ||||||
|  |   color: var(--color-icon-github); } | ||||||
|  |   button.toggle-theme:hover { | ||||||
|  |     color: var(--color-icon-github-hover); } | ||||||
|  |   button.toggle-theme svg { | ||||||
|  |     fill: currentColor; } | ||||||
|  |   button.toggle-theme .icon-light { | ||||||
|  |     display: block; } | ||||||
|  |   button.toggle-theme .icon-dark { | ||||||
|  |     display: none; } | ||||||
|  |   button.toggle-theme.dark .icon-light { | ||||||
|  |     display: none; } | ||||||
|  |   button.toggle-theme.dark .icon-dark { | ||||||
|  |     display: block; } | ||||||
|  |  | ||||||
| .pure-menu-horizontal { | .pure-menu-horizontal { | ||||||
|   background: #fff; |   background: var(--color-background); | ||||||
|   padding: 5px; |   padding: 5px; | ||||||
|   display: flex; |   display: flex; | ||||||
|   justify-content: space-between; |   justify-content: space-between; | ||||||
|   border-bottom: 2px solid #ed5900; |   border-bottom: 2px solid var(--color-menu-accent); | ||||||
|   align-items: center; } |   align-items: center; } | ||||||
|  |  | ||||||
|  | .pure-menu-heading { | ||||||
|  |   color: var(--color-text-menu-heading); } | ||||||
|  |  | ||||||
|  | .pure-menu-link { | ||||||
|  |   color: var(--color-text-menu-link); } | ||||||
|  |   .pure-menu-link:hover { | ||||||
|  |     background-color: var(--color-background-menu-link-hover); | ||||||
|  |     color: var(--color-text-menu-link-hover); } | ||||||
|  |  | ||||||
| section.content { | section.content { | ||||||
|   padding-top: 5em; |   padding-top: 5em; | ||||||
|   padding-bottom: 1em; |   padding-bottom: 1em; | ||||||
| @@ -38,7 +324,8 @@ section.content { | |||||||
|   justify-content: center; } |   justify-content: center; } | ||||||
|  |  | ||||||
| code { | code { | ||||||
|   background: #eee; } |   background: var(--color-background-code); | ||||||
|  |   color: var(--color-text); } | ||||||
|  |  | ||||||
| /* table related */ | /* table related */ | ||||||
| .watch-table { | .watch-table { | ||||||
| @@ -47,7 +334,7 @@ code { | |||||||
|   .watch-table tr.unviewed { |   .watch-table tr.unviewed { | ||||||
|     font-weight: bold; } |     font-weight: bold; } | ||||||
|   .watch-table .error { |   .watch-table .error { | ||||||
|     color: #a00; } |     color: var(--color-error); } | ||||||
|   .watch-table td { |   .watch-table td { | ||||||
|     white-space: nowrap; } |     white-space: nowrap; } | ||||||
|   .watch-table td.title-col { |   .watch-table td.title-col { | ||||||
| @@ -55,12 +342,19 @@ code { | |||||||
|     white-space: normal; } |     white-space: normal; } | ||||||
|   .watch-table th { |   .watch-table th { | ||||||
|     white-space: nowrap; } |     white-space: nowrap; } | ||||||
|   .watch-table .title-col a[target="_blank"]::after, .watch-table .current-diff-url::after { |     .watch-table th a { | ||||||
|  |       font-weight: normal; } | ||||||
|  |       .watch-table th a.active { | ||||||
|  |         font-weight: bolder; } | ||||||
|  |       .watch-table th a.inactive .arrow { | ||||||
|  |         display: none; } | ||||||
|  |   .watch-table .title-col a[target="_blank"]::after, | ||||||
|  |   .watch-table .current-diff-url::after { | ||||||
|     content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==); |     content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==); | ||||||
|     margin: 0 3px 0 5px; } |     margin: 0 3px 0 5px; } | ||||||
|  |  | ||||||
| .watch-tag-list { | .watch-tag-list { | ||||||
|   color: #e70069; |   color: var(--color-text-watch-tag-list); | ||||||
|   white-space: nowrap; } |   white-space: nowrap; } | ||||||
|  |  | ||||||
| .box { | .box { | ||||||
| @@ -83,9 +377,10 @@ code { | |||||||
|  |  | ||||||
| body:after { | body:after { | ||||||
|   content: ""; |   content: ""; | ||||||
|   background: linear-gradient(130deg, #5ad8f7, #2f50af 41.07%, #9150bf 84.05%); } |   background: linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%); } | ||||||
|  |  | ||||||
| body:after, body:before { | body:after, | ||||||
|  | body:before { | ||||||
|   display: block; |   display: block; | ||||||
|   height: 650px; |   height: 650px; | ||||||
|   position: absolute; |   position: absolute; | ||||||
| @@ -101,115 +396,116 @@ body::before { | |||||||
|   content: ""; |   content: ""; | ||||||
|   background-size: cover; } |   background-size: cover; } | ||||||
|  |  | ||||||
| body:after, body:before { | body:after, | ||||||
|  | body:before { | ||||||
|   -webkit-clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); |   -webkit-clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); | ||||||
|   clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); } |   clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); } | ||||||
|  |  | ||||||
| .arrow { |  | ||||||
|   border: solid black; |  | ||||||
|   border-width: 0 3px 3px 0; |  | ||||||
|   display: inline-block; |  | ||||||
|   padding: 3px; } |  | ||||||
|   .arrow.right { |  | ||||||
|     transform: rotate(-45deg); |  | ||||||
|     -webkit-transform: rotate(-45deg); } |  | ||||||
|   .arrow.left { |  | ||||||
|     transform: rotate(135deg); |  | ||||||
|     -webkit-transform: rotate(135deg); } |  | ||||||
|   .arrow.up { |  | ||||||
|     transform: rotate(-135deg); |  | ||||||
|     -webkit-transform: rotate(-135deg); } |  | ||||||
|   .arrow.down { |  | ||||||
|     transform: rotate(45deg); |  | ||||||
|     -webkit-transform: rotate(45deg); } |  | ||||||
|  |  | ||||||
| .button-small { | .button-small { | ||||||
|   font-size: 85%; } |   font-size: 85%; } | ||||||
|  |  | ||||||
| .fetch-error { | .fetch-error { | ||||||
|   padding-top: 1em; |   padding-top: 1em; | ||||||
|   font-size: 60%; |   font-size: 80%; | ||||||
|   max-width: 400px; |   max-width: 400px; | ||||||
|   display: block; } |   display: block; } | ||||||
|  |  | ||||||
|  | .pure-button-primary, | ||||||
|  | a.pure-button-primary, | ||||||
|  | .pure-button-selected, | ||||||
|  | a.pure-button-selected { | ||||||
|  |   background-color: var(--color-background-button-primary); } | ||||||
|  |  | ||||||
| .button-secondary { | .button-secondary { | ||||||
|   color: white; |   color: var(--color-text-button); | ||||||
|   border-radius: 4px; |   border-radius: 4px; | ||||||
|   text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); } |   text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); } | ||||||
|  |  | ||||||
| .button-success { | .button-success { | ||||||
|   background: #1cb841; |   background: var(--color-background-button-success); } | ||||||
|   /* this is a green */ } |  | ||||||
|  |  | ||||||
| .button-tag { | .button-tag { | ||||||
|   background: #636363; |   background: var(--color-background-button-tag); | ||||||
|   color: #fff; |   color: var(--color-text-button); | ||||||
|   font-size: 65%; |   font-size: 65%; | ||||||
|   border-bottom-left-radius: initial; |   border-bottom-left-radius: initial; | ||||||
|   border-bottom-right-radius: initial; } |   border-bottom-right-radius: initial; } | ||||||
|   .button-tag.active { |   .button-tag.active { | ||||||
|     background: #9c9c9c; |     background: var(--color-background-button-tag-active); | ||||||
|     font-weight: bold; } |     font-weight: bold; } | ||||||
|  |  | ||||||
| .button-error { | .button-error { | ||||||
|   background: #ca3c3c; |   background: var(--color-background-button-error); | ||||||
|   /* this is a maroon */ } |   color: var(--color-text-button-error); } | ||||||
|  |  | ||||||
| .button-warning { | .button-warning { | ||||||
|   background: #df7514; |   background: var(--color-background-button-warning); | ||||||
|   /* this is an orange */ } |   color: var(--color-text-button-warning); } | ||||||
|  |  | ||||||
| .button-secondary { | .button-secondary { | ||||||
|   background: #42b8dd; |   background: var(--color-background-button-secondary); } | ||||||
|   /* this is a light blue */ } |  | ||||||
|  |  | ||||||
| .button-cancel { | .button-cancel { | ||||||
|   background: #c8c8c8; |   background: var(--color-background-button-cancel); } | ||||||
|   /* this is a green */ } |  | ||||||
|  | #save_button { | ||||||
|  |   margin-right: 1rem; } | ||||||
|  |  | ||||||
| .messages li { | .messages li { | ||||||
|   list-style: none; |   list-style: none; | ||||||
|   padding: 1em; |   padding: 1em; | ||||||
|   border-radius: 10px; |   border-radius: 10px; | ||||||
|   color: #fff; |   color: var(--color-text-messages); | ||||||
|   font-weight: bold; } |   font-weight: bold; } | ||||||
|   .messages li.message { |   .messages li.message { | ||||||
|     background: rgba(255, 255, 255, 0.2); } |     background: var(--color-background-messages-message); } | ||||||
|   .messages li.error { |   .messages li.error { | ||||||
|     background: rgba(255, 1, 1, 0.5); } |     background: var(--color-background-messages-error); } | ||||||
|   .messages li.notice { |   .messages li.notice { | ||||||
|     background: rgba(255, 255, 255, 0.5); } |     background: var(--color-background-messages-notice); } | ||||||
|  |  | ||||||
| .messages.with-share-link > *:hover { | .messages.with-share-link > *:hover { | ||||||
|   cursor: pointer; } |   cursor: pointer; } | ||||||
|  |  | ||||||
|  | .notifications-wrapper { | ||||||
|  |   padding: 0.5rem 0 1rem 0; } | ||||||
|  |  | ||||||
| #notification-customisation { | #notification-customisation { | ||||||
|   border: 1px solid #ccc; |   border: 1px solid var(--color-border-notification); | ||||||
|   padding: 0.5rem; |   padding: 0.5rem; | ||||||
|   border-radius: 5px; } |   border-radius: 5px; } | ||||||
|  |  | ||||||
| #notification-error-log { | #notification-error-log { | ||||||
|   border: 1px solid #ccc; |   border: 1px solid var(--color-border-notification); | ||||||
|   padding: 1rem; |   padding: 1rem; | ||||||
|   border-radius: 5px; |   border-radius: 5px; | ||||||
|   overflow-wrap: break-word; } |   overflow-wrap: break-word; } | ||||||
|  |  | ||||||
| #token-table.pure-table td, #token-table.pure-table th { | #token-table.pure-table td, | ||||||
|  | #token-table.pure-table th { | ||||||
|   font-size: 80%; } |   font-size: 80%; } | ||||||
|  |  | ||||||
| #new-watch-form { | #new-watch-form { | ||||||
|   background: rgba(0, 0, 0, 0.05); |   background: var(--color-background-new-watch-form); | ||||||
|   padding: 1em; |   padding: 1em; | ||||||
|   border-radius: 10px; |   border-radius: 10px; | ||||||
|   margin-bottom: 1em; } |   margin-bottom: 1em; } | ||||||
|   #new-watch-form input { |   #new-watch-form input { | ||||||
|     width: auto !important; |     display: inline-block; | ||||||
|     display: inline-block; } |     margin-bottom: 5px; } | ||||||
|  |   #new-watch-form input:not(.pure-button) { | ||||||
|  |     background-color: var(--color-background-new-watch-input); | ||||||
|  |     color: var(--color-text-new-watch-input); } | ||||||
|   #new-watch-form .label { |   #new-watch-form .label { | ||||||
|     display: none; } |     display: none; } | ||||||
|   #new-watch-form legend { |   #new-watch-form legend { | ||||||
|     color: #fff; |     color: var(--color-text-legend); | ||||||
|     font-weight: bold; } |     font-weight: bold; } | ||||||
|  |   #new-watch-form #watch-add-wrapper-zone > div { | ||||||
|  |     display: inline-block; } | ||||||
|  |   @media only screen and (max-width: 760px) { | ||||||
|  |     #new-watch-form #watch-add-wrapper-zone #url { | ||||||
|  |       width: 100%; } } | ||||||
|  |  | ||||||
| #diff-col { | #diff-col { | ||||||
|   padding-left: 40px; } |   padding-left: 40px; } | ||||||
| @@ -218,14 +514,14 @@ body:after, body:before { | |||||||
|   position: fixed; |   position: fixed; | ||||||
|   left: 0px; |   left: 0px; | ||||||
|   top: 120px; |   top: 120px; | ||||||
|   background: #fff; |   background: var(--color-background); | ||||||
|   padding: 10px; |   padding: 10px; | ||||||
|   border-top-right-radius: 5px; |   border-top-right-radius: 5px; | ||||||
|   border-bottom-right-radius: 5px; |   border-bottom-right-radius: 5px; | ||||||
|   box-shadow: 5px 0 5px -2px #888; } |   box-shadow: 1px 1px 4px var(--color-shadow-jump); } | ||||||
|   #diff-jump a { |   #diff-jump a { | ||||||
|     color: #1b98f8; |     color: var(--color-link); | ||||||
|     cursor: grabbing; |     cursor: pointer; | ||||||
|     -moz-user-select: none; |     -moz-user-select: none; | ||||||
|     -webkit-user-select: none; |     -webkit-user-select: none; | ||||||
|     -ms-user-select: none; |     -ms-user-select: none; | ||||||
| @@ -234,8 +530,8 @@ body:after, body:before { | |||||||
|  |  | ||||||
| footer { | footer { | ||||||
|   padding: 10px; |   padding: 10px; | ||||||
|   background: #fff; |   background: var(--color-background); | ||||||
|   color: #444; |   color: var(--color-text-footer); | ||||||
|   text-align: center; } |   text-align: center; } | ||||||
|  |  | ||||||
| #feed-icon { | #feed-icon { | ||||||
| @@ -243,18 +539,18 @@ footer { | |||||||
|  |  | ||||||
| #top-right-menu { | #top-right-menu { | ||||||
|   /* |   /* | ||||||
|     position: absolute; |       position: absolute; | ||||||
|     right: 0px; |       right: 0px; | ||||||
|     background: linear-gradient(to right, #fff0, #fff 10%); |       background: linear-gradient(to right, #fff0, #fff 10%); | ||||||
|     padding-left: 20px; |       padding-left: 20px; | ||||||
|     padding-right: 10px; |       padding-right: 10px; | ||||||
|     */ } |       */ } | ||||||
|  |  | ||||||
| .sticky-tab { | .sticky-tab { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   top: 60px; |   top: 60px; | ||||||
|   font-size: 65%; |   font-size: 65%; | ||||||
|   background: #fff; |   background: var(--color-background); | ||||||
|   padding: 10px; } |   padding: 10px; } | ||||||
|   .sticky-tab#left-sticky { |   .sticky-tab#left-sticky { | ||||||
|     left: 0px; } |     left: 0px; } | ||||||
| @@ -266,20 +562,25 @@ footer { | |||||||
|     font-weight: bold; } |     font-weight: bold; } | ||||||
|  |  | ||||||
| #new-version-text a { | #new-version-text a { | ||||||
|   color: #e07171; } |   color: var(--color-link-new-version); } | ||||||
|  |  | ||||||
| .paused-state.state-False img { | .watch-controls { | ||||||
|   opacity: 0.2; } |   color: #f8321b; | ||||||
|  |   /* default */ } | ||||||
| .paused-state.state-False:hover img { |   .watch-controls .state-on img { | ||||||
|   opacity: 0.8; } |     opacity: 0.8; } | ||||||
|  |   .watch-controls img { | ||||||
|  |     opacity: 0.2; } | ||||||
|  |   .watch-controls img:hover { | ||||||
|  |     transition: opacity 0.3s; | ||||||
|  |     opacity: 0.8; } | ||||||
|  |  | ||||||
| .monospaced-textarea textarea { | .monospaced-textarea textarea { | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   font-family: monospace; |   font-family: monospace; | ||||||
|   white-space: pre; |   white-space: pre; | ||||||
|   overflow-wrap: normal; |   overflow-wrap: normal; | ||||||
|   overflow-x: scroll; } |   overflow-x: auto; } | ||||||
|  |  | ||||||
| .pure-form { | .pure-form { | ||||||
|   /* The input fields with errors */ |   /* The input fields with errors */ | ||||||
| @@ -289,27 +590,39 @@ footer { | |||||||
|     .pure-form fieldset ul { |     .pure-form fieldset ul { | ||||||
|       padding-bottom: 0px; |       padding-bottom: 0px; | ||||||
|       margin-bottom: 0px; } |       margin-bottom: 0px; } | ||||||
|   .pure-form .pure-control-group, .pure-form .pure-group, .pure-form .pure-controls { |   .pure-form .pure-control-group, | ||||||
|  |   .pure-form .pure-group, | ||||||
|  |   .pure-form .pure-controls { | ||||||
|     padding-bottom: 1em; } |     padding-bottom: 1em; } | ||||||
|     .pure-form .pure-control-group div, .pure-form .pure-group div, .pure-form .pure-controls div { |     .pure-form .pure-control-group div, | ||||||
|  |     .pure-form .pure-group div, | ||||||
|  |     .pure-form .pure-controls div { | ||||||
|       margin: 0px; } |       margin: 0px; } | ||||||
|     .pure-form .pure-control-group .checkbox > *, .pure-form .pure-group .checkbox > *, .pure-form .pure-controls .checkbox > * { |     .pure-form .pure-control-group .checkbox > *, | ||||||
|  |     .pure-form .pure-group .checkbox > *, | ||||||
|  |     .pure-form .pure-controls .checkbox > * { | ||||||
|       display: inline; |       display: inline; | ||||||
|       vertical-align: middle; } |       vertical-align: middle; } | ||||||
|     .pure-form .pure-control-group .checkbox > label, .pure-form .pure-group .checkbox > label, .pure-form .pure-controls .checkbox > label { |     .pure-form .pure-control-group .checkbox > label, | ||||||
|  |     .pure-form .pure-group .checkbox > label, | ||||||
|  |     .pure-form .pure-controls .checkbox > label { | ||||||
|       padding-left: 5px; } |       padding-left: 5px; } | ||||||
|  |     .pure-form .pure-control-group legend, | ||||||
|  |     .pure-form .pure-group legend, | ||||||
|  |     .pure-form .pure-controls legend { | ||||||
|  |       color: var(--color-text-legend); } | ||||||
|   .pure-form .error input { |   .pure-form .error input { | ||||||
|     background-color: #ffebeb; } |     background-color: var(--color-error-input); } | ||||||
|   .pure-form ul.errors { |   .pure-form ul.errors { | ||||||
|     padding: .5em .6em; |     padding: .5em .6em; | ||||||
|     border: 1px solid #dd0000; |     border: 1px solid var(--color-error-list); | ||||||
|     border-radius: 4px; |     border-radius: 4px; | ||||||
|     vertical-align: middle; |     vertical-align: middle; | ||||||
|     -webkit-box-sizing: border-box; |     -webkit-box-sizing: border-box; | ||||||
|     box-sizing: border-box; } |     box-sizing: border-box; } | ||||||
|     .pure-form ul.errors li { |     .pure-form ul.errors li { | ||||||
|       margin-left: 1em; |       margin-left: 1em; | ||||||
|       color: #dd0000; } |       color: var(--color-error-list); } | ||||||
|   .pure-form label { |   .pure-form label { | ||||||
|     font-weight: bold; } |     font-weight: bold; } | ||||||
|   .pure-form textarea { |   .pure-form textarea { | ||||||
| @@ -343,21 +656,27 @@ footer { | |||||||
|   input[type='text'] { |   input[type='text'] { | ||||||
|     width: 100%; } |     width: 100%; } | ||||||
|   /* |   /* | ||||||
| Max width before this PARTICULAR table gets nasty |   Max width before this PARTICULAR table gets nasty | ||||||
| This query will take effect for any screen smaller than 760px |   This query will take effect for any screen smaller than 760px | ||||||
| and also iPads specifically. |   and also iPads specifically. | ||||||
| */ |   */ | ||||||
|   .watch-table { |   .watch-table { | ||||||
|     /* Force table to not be like tables anymore */ |     /* Force table to not be like tables anymore */ | ||||||
|     /* Force table to not be like tables anymore */ |     /* Force table to not be like tables anymore */ | ||||||
|     /* Hide table headers (but not display: none;, for accessibility) */ } |     /* Hide table headers (but not display: none;, for accessibility) */ } | ||||||
|     .watch-table thead, .watch-table tbody, .watch-table th, .watch-table td, .watch-table tr { |     .watch-table thead, | ||||||
|  |     .watch-table tbody, | ||||||
|  |     .watch-table th, | ||||||
|  |     .watch-table td, | ||||||
|  |     .watch-table tr { | ||||||
|       display: block; } |       display: block; } | ||||||
|  |     .watch-table .last-checked > span { | ||||||
|  |       vertical-align: middle; } | ||||||
|     .watch-table .last-checked::before { |     .watch-table .last-checked::before { | ||||||
|       color: #555; |       color: var(--color-last-checked); | ||||||
|       content: "Last Checked "; } |       content: "Last Checked "; } | ||||||
|     .watch-table .last-changed::before { |     .watch-table .last-changed::before { | ||||||
|       color: #555; |       color: var(--color-last-checked); | ||||||
|       content: "Last Changed "; } |       content: "Last Changed "; } | ||||||
|     .watch-table td.inline { |     .watch-table td.inline { | ||||||
|       display: inline-block; } |       display: inline-block; } | ||||||
| @@ -365,12 +684,14 @@ and also iPads specifically. | |||||||
|       position: absolute; |       position: absolute; | ||||||
|       top: -9999px; |       top: -9999px; | ||||||
|       left: -9999px; } |       left: -9999px; } | ||||||
|     .watch-table .pure-table td, .watch-table .pure-table th { |     .watch-table .pure-table td, | ||||||
|  |     .watch-table .pure-table th { | ||||||
|       border: none; } |       border: none; } | ||||||
|     .watch-table td { |     .watch-table td { | ||||||
|       /* Behave  like a "row" */ |       /* Behave  like a "row" */ | ||||||
|       border: none; |       border: none; | ||||||
|       border-bottom: 1px solid #eee; } |       border-bottom: 1px solid var(--color-border-watch-table-cell); | ||||||
|  |       vertical-align: middle; } | ||||||
|       .watch-table td:before { |       .watch-table td:before { | ||||||
|         /* Top/left values mimic padding */ |         /* Top/left values mimic padding */ | ||||||
|         top: 6px; |         top: 6px; | ||||||
| @@ -379,12 +700,66 @@ and also iPads specifically. | |||||||
|         padding-right: 10px; |         padding-right: 10px; | ||||||
|         white-space: nowrap; } |         white-space: nowrap; } | ||||||
|     .watch-table.pure-table-striped tr { |     .watch-table.pure-table-striped tr { | ||||||
|       background-color: #fff; } |       background-color: var(--color-table-background); } | ||||||
|     .watch-table.pure-table-striped tr:nth-child(2n-1) { |     .watch-table.pure-table-striped tr:nth-child(2n-1) { | ||||||
|       background-color: #eee; } |       background-color: var(--color-table-stripe); } | ||||||
|     .watch-table.pure-table-striped tr:nth-child(2n-1) td { |     .watch-table.pure-table-striped tr:nth-child(2n-1) td { | ||||||
|       background-color: inherit; } } |       background-color: inherit; } } | ||||||
|  |  | ||||||
|  | .pure-table { | ||||||
|  |   border-color: var(--color-border-table-cell); } | ||||||
|  |   .pure-table thead { | ||||||
|  |     background-color: var(--color-background-table-thead); | ||||||
|  |     color: var(--color-text); } | ||||||
|  |   .pure-table td, | ||||||
|  |   .pure-table th { | ||||||
|  |     border-left-color: var(--color-border-table-cell); } | ||||||
|  |  | ||||||
|  | .pure-table-striped tr:nth-child(2n-1) td { | ||||||
|  |   background-color: var(--color-table-stripe); } | ||||||
|  |  | ||||||
|  | .pure-form input[type=color], | ||||||
|  | .pure-form input[type=date], | ||||||
|  | .pure-form input[type=datetime-local], | ||||||
|  | .pure-form input[type=datetime], | ||||||
|  | .pure-form input[type=email], | ||||||
|  | .pure-form input[type=month], | ||||||
|  | .pure-form input[type=number], | ||||||
|  | .pure-form input[type=password], | ||||||
|  | .pure-form input[type=search], | ||||||
|  | .pure-form input[type=tel], | ||||||
|  | .pure-form input[type=text], | ||||||
|  | .pure-form input[type=time], | ||||||
|  | .pure-form input[type=url], | ||||||
|  | .pure-form input[type=week], | ||||||
|  | .pure-form select, | ||||||
|  | .pure-form textarea { | ||||||
|  |   border: var(--color-border-input); | ||||||
|  |   box-shadow: inset 0 1px 3px var(--color-shadow-input); | ||||||
|  |   background-color: var(--color-background-input); | ||||||
|  |   color: var(--color-text-input); } | ||||||
|  |   .pure-form input[type=color]:active, | ||||||
|  |   .pure-form input[type=date]:active, | ||||||
|  |   .pure-form input[type=datetime-local]:active, | ||||||
|  |   .pure-form input[type=datetime]:active, | ||||||
|  |   .pure-form input[type=email]:active, | ||||||
|  |   .pure-form input[type=month]:active, | ||||||
|  |   .pure-form input[type=number]:active, | ||||||
|  |   .pure-form input[type=password]:active, | ||||||
|  |   .pure-form input[type=search]:active, | ||||||
|  |   .pure-form input[type=tel]:active, | ||||||
|  |   .pure-form input[type=text]:active, | ||||||
|  |   .pure-form input[type=time]:active, | ||||||
|  |   .pure-form input[type=url]:active, | ||||||
|  |   .pure-form input[type=week]:active, | ||||||
|  |   .pure-form select:active, | ||||||
|  |   .pure-form textarea:active { | ||||||
|  |     background-color: var(--color-background-input); } | ||||||
|  |  | ||||||
|  | input::placeholder, | ||||||
|  | textarea::placeholder { | ||||||
|  |   color: var(--color-text-input-placeholder); } | ||||||
|  |  | ||||||
| /** Desktop vs mobile input field strategy | /** Desktop vs mobile input field strategy | ||||||
| - We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out | - We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out | ||||||
| - Rely always on width in CSS | - Rely always on width in CSS | ||||||
| @@ -401,25 +776,29 @@ and also iPads specifically. | |||||||
|   .tabs ul li { |   .tabs ul li { | ||||||
|     margin-right: 3px; |     margin-right: 3px; | ||||||
|     display: inline-block; |     display: inline-block; | ||||||
|     color: #fff; |     color: var(--color-text-tab); | ||||||
|     border-top-left-radius: 5px; |     border-top-left-radius: 5px; | ||||||
|     border-top-right-radius: 5px; |     border-top-right-radius: 5px; | ||||||
|     background-color: rgba(255, 255, 255, 0.2); } |     background-color: var(--color-background-tab); } | ||||||
|     .tabs ul li.active, .tabs ul li :target { |     .tabs ul li:not(.active):hover { | ||||||
|       background-color: #fff; } |       background-color: var(--color-background-tab-hover); } | ||||||
|       .tabs ul li.active a, .tabs ul li :target a { |     .tabs ul li.active, | ||||||
|         color: #222; |     .tabs ul li :target { | ||||||
|  |       background-color: var(--color-background); } | ||||||
|  |       .tabs ul li.active a, | ||||||
|  |       .tabs ul li :target a { | ||||||
|  |         color: var(--color-text-tab-active); | ||||||
|         font-weight: bold; } |         font-weight: bold; } | ||||||
|     .tabs ul li a { |     .tabs ul li a { | ||||||
|       display: block; |       display: block; | ||||||
|       padding: 0.8em; |       padding: 0.8em; | ||||||
|       color: #fff; } |       color: var(--color-text-tab); } | ||||||
|  |  | ||||||
| .pure-form-stacked > div:first-child { | .pure-form-stacked > div:first-child { | ||||||
|   display: block; } |   display: block; } | ||||||
|  |  | ||||||
| .login-form .inner { | .login-form .inner { | ||||||
|   background: #fff; |   background: var(--color-background); | ||||||
|   padding: 20px; |   padding: 20px; | ||||||
|   border-radius: 5px; } |   border-radius: 5px; } | ||||||
|  |  | ||||||
| @@ -430,7 +809,7 @@ and also iPads specifically. | |||||||
|   .tab-pane-inner:target { |   .tab-pane-inner:target { | ||||||
|     display: block; } |     display: block; } | ||||||
|  |  | ||||||
| #beta-logo { | .beta-logo { | ||||||
|   height: 50px; |   height: 50px; | ||||||
|   right: -3px; |   right: -3px; | ||||||
|   top: -3px; |   top: -3px; | ||||||
| @@ -439,6 +818,9 @@ and also iPads specifically. | |||||||
| #selector-header { | #selector-header { | ||||||
|   padding-bottom: 1em; } |   padding-bottom: 1em; } | ||||||
|  |  | ||||||
|  | body.full-width .edit-form { | ||||||
|  |   width: 95%; } | ||||||
|  |  | ||||||
| .edit-form { | .edit-form { | ||||||
|   min-width: 70%; |   min-width: 70%; | ||||||
|   /* so it cant overflow */ |   /* so it cant overflow */ | ||||||
| @@ -446,13 +828,14 @@ and also iPads specifically. | |||||||
|   .edit-form .box-wrap { |   .edit-form .box-wrap { | ||||||
|     position: relative; } |     position: relative; } | ||||||
|   .edit-form .inner { |   .edit-form .inner { | ||||||
|     background: #fff; |     background: var(--color-background); | ||||||
|     padding: 20px; } |     padding: 20px; } | ||||||
|   .edit-form #actions { |   .edit-form #actions { | ||||||
|     display: block; |     display: block; | ||||||
|     background: #fff; } |     background: var(--color-background); } | ||||||
|   .edit-form .pure-form-message-inline { |   .edit-form .pure-form-message-inline { | ||||||
|     padding-left: 0; } |     padding-left: 0; | ||||||
|  |     color: var(--color-text-input-description); } | ||||||
|  |  | ||||||
| ul { | ul { | ||||||
|   padding-left: 1em; |   padding-left: 1em; | ||||||
| @@ -465,7 +848,7 @@ ul { | |||||||
|     width: 5em; } |     width: 5em; } | ||||||
|  |  | ||||||
| #selector-wrapper { | #selector-wrapper { | ||||||
|   height: 600px; |   height: 100%; | ||||||
|   overflow-y: scroll; |   overflow-y: scroll; | ||||||
|   position: relative; } |   position: relative; } | ||||||
|   #selector-wrapper > img { |   #selector-wrapper > img { | ||||||
| @@ -489,4 +872,57 @@ ul { | |||||||
|   cursor: pointer; } |   cursor: pointer; } | ||||||
|  |  | ||||||
| #api-key-copy { | #api-key-copy { | ||||||
|   color: #0078e7; } |   color: var(--color-api-key); } | ||||||
|  |  | ||||||
|  | .button-green { | ||||||
|  |   background-color: var(--color-background-button-green); } | ||||||
|  |  | ||||||
|  | .button-red { | ||||||
|  |   background-color: var(--color-background-button-red); } | ||||||
|  |  | ||||||
|  | .noselect { | ||||||
|  |   -webkit-touch-callout: none; | ||||||
|  |   /* iOS Safari */ | ||||||
|  |   -webkit-user-select: none; | ||||||
|  |   /* Safari */ | ||||||
|  |   -moz-user-select: none; | ||||||
|  |   /* Old versions of Firefox */ | ||||||
|  |   -ms-user-select: none; | ||||||
|  |   /* Internet Explorer/Edge */ | ||||||
|  |   user-select: none; | ||||||
|  |   /* Non-prefixed version, currently | ||||||
|  |     supported by Chrome, Edge, Opera and Firefox */ } | ||||||
|  |  | ||||||
|  | .snapshot-age { | ||||||
|  |   padding: 4px; | ||||||
|  |   margin: 0.5rem 0; | ||||||
|  |   background-color: var(--color-background-snapshot-age); | ||||||
|  |   border-radius: 3px; | ||||||
|  |   font-weight: bold; | ||||||
|  |   margin-bottom: 4px; } | ||||||
|  |   .snapshot-age.error { | ||||||
|  |     background-color: var(--color-error-background-snapshot-age); | ||||||
|  |     color: var(--color-error-text-snapshot-age); } | ||||||
|  |  | ||||||
|  | #checkbox-operations { | ||||||
|  |   background: var(--color-background-checkbox-operations); | ||||||
|  |   padding: 1em; | ||||||
|  |   border-radius: 10px; | ||||||
|  |   margin-bottom: 1em; | ||||||
|  |   display: none; } | ||||||
|  |  | ||||||
|  | .checkbox-uuid > * { | ||||||
|  |   vertical-align: middle; } | ||||||
|  |  | ||||||
|  | .inline-warning { | ||||||
|  |   border: 1px solid var(--color-border-warning); | ||||||
|  |   padding: 0.5rem; | ||||||
|  |   border-radius: 5px; | ||||||
|  |   color: var(--color-warning); } | ||||||
|  |   .inline-warning > span { | ||||||
|  |     display: inline-block; | ||||||
|  |     vertical-align: middle; } | ||||||
|  |   .inline-warning img.inline-warning-icon { | ||||||
|  |     display: inline; | ||||||
|  |     height: 26px; | ||||||
|  |     vertical-align: middle; } | ||||||
|   | |||||||
| @@ -1,703 +0,0 @@ | |||||||
| /* |  | ||||||
|  * -- BASE STYLES -- |  | ||||||
|  * Most of these are inherited from Base, but I want to change a few. |  | ||||||
|  * nvm use v14.18.1 |  | ||||||
|  * npm install |  | ||||||
|  * npm run build |  | ||||||
|  * or npm run watch |  | ||||||
|  */ |  | ||||||
| body { |  | ||||||
|   color: #333; |  | ||||||
|   background: #262626; |  | ||||||
| } |  | ||||||
| .pure-table-even { |  | ||||||
|   background: #fff; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* Some styles from https://css-tricks.com/ */ |  | ||||||
| a { |  | ||||||
|   text-decoration: none; |  | ||||||
|   color: #1b98f8; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| a.github-link { |  | ||||||
|   color: #fff; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .pure-menu-horizontal { |  | ||||||
|   background: #fff; |  | ||||||
|   padding: 5px; |  | ||||||
|   display: flex; |  | ||||||
|   justify-content: space-between; |  | ||||||
|   border-bottom: 2px solid #ed5900; |  | ||||||
|   align-items: center; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| section.content { |  | ||||||
|   padding-top: 5em; |  | ||||||
|   padding-bottom: 1em; |  | ||||||
|   flex-direction: column; |  | ||||||
|   display: flex; |  | ||||||
|   align-items: center; |  | ||||||
|   justify-content: center; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| code { |  | ||||||
|   background: #eee; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* table related */ |  | ||||||
| .watch-table { |  | ||||||
|   width: 100%; |  | ||||||
|   font-size: 80%; |  | ||||||
|  |  | ||||||
|   tr.unviewed { |  | ||||||
|     font-weight: bold; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .error { |  | ||||||
|     color: #a00; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   td { |  | ||||||
|     white-space: nowrap; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   td.title-col { |  | ||||||
|     word-break: break-all; |  | ||||||
|     white-space: normal; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   th { |  | ||||||
|     white-space: nowrap; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .title-col a[target="_blank"]::after, .current-diff-url::after { |  | ||||||
|     content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==); |  | ||||||
|     margin: 0 3px 0 5px; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .watch-tag-list { |  | ||||||
|   color: #e70069; |  | ||||||
|   white-space: nowrap; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .box { |  | ||||||
|   max-width: 80%; |  | ||||||
|   flex-direction: column; |  | ||||||
|   display: flex; |  | ||||||
|   justify-content: center; |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| #post-list-buttons { |  | ||||||
|   text-align: right; |  | ||||||
|   padding: 0px; |  | ||||||
|   margin: 0px; |  | ||||||
|  |  | ||||||
|   li { |  | ||||||
|     display: inline-block; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   a { |  | ||||||
|     border-top-left-radius: initial; |  | ||||||
|     border-top-right-radius: initial; |  | ||||||
|     border-bottom-left-radius: 5px; |  | ||||||
|     border-bottom-right-radius: 5px; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| body:after { |  | ||||||
|   content: ""; |  | ||||||
|   background: linear-gradient(130deg, #5ad8f7, #2f50af 41.07%, #9150bf 84.05%); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| body:after, body:before { |  | ||||||
|   display: block; |  | ||||||
|   height: 650px; |  | ||||||
|   position: absolute; |  | ||||||
|   top: 0; |  | ||||||
|   left: 0; |  | ||||||
|   width: 100%; |  | ||||||
|   z-index: -1; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| body::after { |  | ||||||
|   opacity: 0.91; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| body::before { |  | ||||||
|   // background-image set in base.html so it works with reverse proxies etc |  | ||||||
|   content: ""; |  | ||||||
|   background-size: cover |  | ||||||
| } |  | ||||||
|  |  | ||||||
| body:after, body:before { |  | ||||||
|   -webkit-clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); |  | ||||||
|   clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .arrow { |  | ||||||
|   border: solid black; |  | ||||||
|   border-width: 0 3px 3px 0; |  | ||||||
|   display: inline-block; |  | ||||||
|   padding: 3px; |  | ||||||
|     &.right { |  | ||||||
|       transform: rotate(-45deg); |  | ||||||
|       -webkit-transform: rotate(-45deg); |  | ||||||
|     } |  | ||||||
|     &.left { |  | ||||||
|       transform: rotate(135deg); |  | ||||||
|       -webkit-transform: rotate(135deg); |  | ||||||
|     } |  | ||||||
|     &.up { |  | ||||||
|       transform: rotate(-135deg); |  | ||||||
|       -webkit-transform: rotate(-135deg); |  | ||||||
|     } |  | ||||||
|     &.down { |  | ||||||
|       transform: rotate(45deg); |  | ||||||
|       -webkit-transform: rotate(45deg); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .button-small { |  | ||||||
|   font-size: 85%; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .fetch-error { |  | ||||||
|   padding-top: 1em; |  | ||||||
|   font-size: 60%; |  | ||||||
|   max-width: 400px; |  | ||||||
|   display: block; |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| .button-secondary { |  | ||||||
|   color: white; |  | ||||||
|   border-radius: 4px; |  | ||||||
|   text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .button-success { |  | ||||||
|   background: rgb(28, 184, 65); |  | ||||||
|   /* this is a green */ |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .button-tag { |  | ||||||
|   background: rgb(99, 99, 99); |  | ||||||
|   color: #fff; |  | ||||||
|   font-size: 65%; |  | ||||||
|   border-bottom-left-radius: initial; |  | ||||||
|   border-bottom-right-radius: initial; |  | ||||||
|  |  | ||||||
|   &.active { |  | ||||||
|     background: #9c9c9c; |  | ||||||
|     font-weight: bold; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .button-error { |  | ||||||
|   background: rgb(202, 60, 60); |  | ||||||
|   /* this is a maroon */ |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .button-warning { |  | ||||||
|   background: rgb(223, 117, 20); |  | ||||||
|   /* this is an orange */ |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .button-secondary { |  | ||||||
|   background: rgb(66, 184, 221); |  | ||||||
|   /* this is a light blue */ |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| .button-cancel { |  | ||||||
|   background: rgb(200, 200, 200); |  | ||||||
|   /* this is a green */ |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .messages { |  | ||||||
|     li { |  | ||||||
|         list-style: none; |  | ||||||
|         padding: 1em; |  | ||||||
|         border-radius: 10px; |  | ||||||
|         color: #fff; |  | ||||||
|         font-weight: bold; |  | ||||||
|         &.message { |  | ||||||
|             background: rgba(255, 255, 255, .2); |  | ||||||
|         } |  | ||||||
|         &.error { |  | ||||||
|             background: rgba(255, 1, 1, .5); |  | ||||||
|         } |  | ||||||
|         &.notice { |  | ||||||
|             background: rgba(255, 255, 255, .5); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     &.with-share-link { |  | ||||||
|      > *:hover { |  | ||||||
|        cursor:pointer; |  | ||||||
|      } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #notification-customisation { |  | ||||||
|     border: 1px solid #ccc; |  | ||||||
|     padding: 0.5rem; |  | ||||||
|     border-radius: 5px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #notification-error-log { |  | ||||||
|     border: 1px solid #ccc; |  | ||||||
|     padding: 1rem; |  | ||||||
|     border-radius: 5px; |  | ||||||
|     overflow-wrap: break-word; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #token-table { |  | ||||||
|     &.pure-table td, &.pure-table th { |  | ||||||
|         font-size: 80%; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #new-watch-form { |  | ||||||
|   background: rgba(0, 0, 0, .05); |  | ||||||
|   padding: 1em; |  | ||||||
|   border-radius: 10px; |  | ||||||
|   margin-bottom: 1em; |  | ||||||
|   input { |  | ||||||
|     width: auto !important; |  | ||||||
|     display: inline-block; |  | ||||||
|   } |  | ||||||
|   .label { |  | ||||||
|     display: none; |  | ||||||
|   } |  | ||||||
|   legend { |  | ||||||
|     color: #fff; |  | ||||||
|     font-weight: bold; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| #diff-col { |  | ||||||
|   padding-left: 40px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #diff-jump { |  | ||||||
|   position: fixed; |  | ||||||
|   left: 0px; |  | ||||||
|   top: 120px; |  | ||||||
|   background: #fff; |  | ||||||
|   padding: 10px; |  | ||||||
|   border-top-right-radius: 5px; |  | ||||||
|   border-bottom-right-radius: 5px; |  | ||||||
|   box-shadow: 5px 0 5px -2px #888; |  | ||||||
|      a { |  | ||||||
|       color: #1b98f8; |  | ||||||
|       cursor: grabbing; |  | ||||||
|       -moz-user-select: none; |  | ||||||
|       -webkit-user-select: none; |  | ||||||
|       -ms-user-select: none; |  | ||||||
|       user-select: none; |  | ||||||
|       -o-user-select: none; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| footer { |  | ||||||
|   padding: 10px; |  | ||||||
|   background: #fff; |  | ||||||
|   color: #444; |  | ||||||
|   text-align: center; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #feed-icon { |  | ||||||
|   vertical-align: middle; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #top-right-menu { |  | ||||||
| // Just let flex overflow the x axis for now |  | ||||||
| /* |  | ||||||
|     position: absolute; |  | ||||||
|     right: 0px; |  | ||||||
|     background: linear-gradient(to right, #fff0, #fff 10%); |  | ||||||
|     padding-left: 20px; |  | ||||||
|     padding-right: 10px; |  | ||||||
|     */ |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .sticky-tab { |  | ||||||
|   position: absolute; |  | ||||||
|   top: 60px; |  | ||||||
|   font-size: 65%; |  | ||||||
|   background: #fff; |  | ||||||
|   padding: 10px; |  | ||||||
|   &#left-sticky { |  | ||||||
|     left: 0px; |  | ||||||
|   } |  | ||||||
|   &#right-sticky { |  | ||||||
|     right: 0px; |  | ||||||
|   } |  | ||||||
|   &#hosted-sticky { |  | ||||||
|     right: 0px; |  | ||||||
|     top: 100px; |  | ||||||
|     font-weight: bold; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #new-version-text a { |  | ||||||
|   color: #e07171; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .paused-state { |  | ||||||
|   &.state-False img { |  | ||||||
|     opacity: 0.2; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   &.state-False:hover img { |  | ||||||
|     opacity: 0.8; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .monospaced-textarea { |  | ||||||
|     textarea { |  | ||||||
|         width: 100%; |  | ||||||
|         font-family: monospace; |  | ||||||
|         white-space: pre; |  | ||||||
|         overflow-wrap: normal; |  | ||||||
|         overflow-x: scroll; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| .pure-form { |  | ||||||
|     fieldset { |  | ||||||
|         padding-top: 0px; |  | ||||||
|         ul { |  | ||||||
|             padding-bottom: 0px; |  | ||||||
|             margin-bottom: 0px; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     .pure-control-group, .pure-group, .pure-controls { |  | ||||||
|         padding-bottom: 1em; |  | ||||||
|         div { |  | ||||||
|             margin: 0px; |  | ||||||
|         } |  | ||||||
|         .checkbox { |  | ||||||
|             > * { |  | ||||||
|               display: inline; |  | ||||||
|               vertical-align: middle; |  | ||||||
|             } |  | ||||||
|             > label { |  | ||||||
|                padding-left: 5px; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|   /* The input fields with errors */ |  | ||||||
|   .error { |  | ||||||
|     input { |  | ||||||
|         background-color: #ffebeb; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /* The list of errors */ |  | ||||||
|   ul.errors { |  | ||||||
|     padding: .5em .6em; |  | ||||||
|     border: 1px solid #dd0000; |  | ||||||
|     border-radius: 4px; |  | ||||||
|     vertical-align: middle; |  | ||||||
|     -webkit-box-sizing: border-box; |  | ||||||
|     box-sizing: border-box; |  | ||||||
|     li { |  | ||||||
|         margin-left: 1em; |  | ||||||
|         color: #dd0000; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   label { |  | ||||||
|     font-weight: bold; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   textarea { |  | ||||||
|     width: 100%; |  | ||||||
|   } |  | ||||||
|   .inline-radio { |  | ||||||
|       ul { |  | ||||||
|         margin: 0px; |  | ||||||
|         list-style: none; |  | ||||||
|         li { |  | ||||||
|             > * { |  | ||||||
|                 display: inline-block; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { |  | ||||||
|   .box { |  | ||||||
|     max-width: 95% |  | ||||||
|   } |  | ||||||
|   .edit-form { |  | ||||||
|     padding: 0.5em; |  | ||||||
|     margin: 0; |  | ||||||
|   } |  | ||||||
|   #nav-menu { |  | ||||||
|     overflow-x: scroll; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 800px) { |  | ||||||
|  |  | ||||||
|   div.sticky-tab#hosted-sticky { |  | ||||||
|     top: 60px; |  | ||||||
|     left: 0px; |  | ||||||
|     right: auto; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   section.content { |  | ||||||
|     padding-top: 110px; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   // Make the tabs easier to hit, they will be all nice and horizontal |  | ||||||
|   div.tabs.collapsable ul li { |  | ||||||
|     display: block; |  | ||||||
|     border-radius: 0px; |  | ||||||
|     margin-right: 0px; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   input[type='text'] { |  | ||||||
|     width: 100%; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
| /* |  | ||||||
| Max width before this PARTICULAR table gets nasty |  | ||||||
| This query will take effect for any screen smaller than 760px |  | ||||||
| and also iPads specifically. |  | ||||||
| */ |  | ||||||
|   .watch-table { |  | ||||||
|     /* Force table to not be like tables anymore */ |  | ||||||
|     thead, tbody, th, td, tr { |  | ||||||
|       display: block; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .last-checked::before { |  | ||||||
|       color: #555; |  | ||||||
|       content: "Last Checked "; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .last-changed::before { |  | ||||||
|       color: #555; |  | ||||||
|       content: "Last Changed "; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /* Force table to not be like tables anymore */ |  | ||||||
|     td.inline { |  | ||||||
|       display: inline-block; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /* Hide table headers (but not display: none;, for accessibility) */ |  | ||||||
|     thead tr { |  | ||||||
|       position: absolute; |  | ||||||
|       top: -9999px; |  | ||||||
|       left: -9999px; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .pure-table td, .pure-table th { |  | ||||||
|       border: none; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     td { |  | ||||||
|       /* Behave  like a "row" */ |  | ||||||
|       border: none; |  | ||||||
|       border-bottom: 1px solid #eee; |  | ||||||
|  |  | ||||||
|       &:before { |  | ||||||
|         /* Top/left values mimic padding */ |  | ||||||
|         top: 6px; |  | ||||||
|         left: 6px; |  | ||||||
|         width: 45%; |  | ||||||
|         padding-right: 10px; |  | ||||||
|         white-space: nowrap; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     &.pure-table-striped { |  | ||||||
|       tr { |  | ||||||
|         background-color: #fff; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       tr:nth-child(2n-1) { |  | ||||||
|         background-color: #eee; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       tr:nth-child(2n-1) td { |  | ||||||
|         background-color: inherit; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| /** Desktop vs mobile input field strategy |  | ||||||
| - We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out |  | ||||||
| - Rely always on width in CSS |  | ||||||
| */ |  | ||||||
| @media only screen and (min-width: 761px) { |  | ||||||
| /* m-d is medium-desktop */ |  | ||||||
|     .m-d { |  | ||||||
|         min-width: 80%; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| .tabs { |  | ||||||
|   ul { |  | ||||||
|     margin: 0px; |  | ||||||
|     padding: 0px; |  | ||||||
|     display:block; |  | ||||||
|     li { |  | ||||||
|       margin-right: 3px; |  | ||||||
|       display: inline-block; |  | ||||||
|       color: #fff; |  | ||||||
|       border-top-left-radius: 5px; |  | ||||||
|       border-top-right-radius: 5px; |  | ||||||
|       background-color: rgba(255, 255, 255, 0.2); |  | ||||||
|  |  | ||||||
|       &.active,:target { |  | ||||||
|         background-color: #fff; |  | ||||||
|         a { |  | ||||||
|           color: #222; |  | ||||||
|           font-weight: bold; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|       a { |  | ||||||
|         display: block; |  | ||||||
|         padding: 0.8em; |  | ||||||
|         color: #fff; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| $form-edge-padding: 20px; |  | ||||||
| .pure-form-stacked { |  | ||||||
|   >div:first-child { |  | ||||||
|     display: block; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .login-form { |  | ||||||
|   .inner { |  | ||||||
|     background: #fff;; |  | ||||||
|     padding: $form-edge-padding; |  | ||||||
|     border-radius: 5px; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .tab-pane-inner { |  | ||||||
|     &:not(:target) { |  | ||||||
|         display: none; |  | ||||||
|     } |  | ||||||
|     &:target { |  | ||||||
|       display: block; |  | ||||||
|     } |  | ||||||
|     // doesnt need padding because theres another row of buttons/activity |  | ||||||
|     padding: 0px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #beta-logo { |  | ||||||
|     height: 50px; |  | ||||||
|     // looks better when it's hanging off a little |  | ||||||
|     right: -3px; |  | ||||||
|     top: -3px; |  | ||||||
|     position: absolute; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #selector-header { |  | ||||||
|     padding-bottom: 1em; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .edit-form { |  | ||||||
|   min-width: 70%; |  | ||||||
|   /* so it cant overflow */ |  | ||||||
|   max-width: 95%; |  | ||||||
|   .box-wrap { |  | ||||||
|     position: relative; |  | ||||||
|   } |  | ||||||
|   .inner { |  | ||||||
|     background: #fff;; |  | ||||||
|     padding: $form-edge-padding; |  | ||||||
|   } |  | ||||||
|   #actions { |  | ||||||
|     display: block; |  | ||||||
|     background: #fff; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .pure-form-message-inline { |  | ||||||
|     padding-left: 0; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| ul { |  | ||||||
|     padding-left: 1em; |  | ||||||
|     padding-top: 0px; |  | ||||||
|     margin-top: 4px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .time-check-widget { |  | ||||||
|     tr { |  | ||||||
|         display: inline; |  | ||||||
|         input[type="number"] { |  | ||||||
|             width: 5em; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #selector-wrapper { |  | ||||||
|  height: 600px; |  | ||||||
|  overflow-y: scroll; |  | ||||||
|  position: relative; |  | ||||||
|     //width: 100%; |  | ||||||
|  > img { |  | ||||||
|     position: absolute; |  | ||||||
|     z-index: 4; |  | ||||||
|     max-width: 100%; |  | ||||||
|  } |  | ||||||
|  >canvas { |  | ||||||
|     position: relative; |  | ||||||
|     z-index: 5; |  | ||||||
|      max-width: 100%; |  | ||||||
|      &:hover { |  | ||||||
|      cursor: pointer; |  | ||||||
|      } |  | ||||||
|  } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #selector-current-xpath { |  | ||||||
|   font-size: 80%; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #webdriver-override-options { |  | ||||||
|         input[type="number"] { |  | ||||||
|             width: 5em; |  | ||||||
|         } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #api-key { |  | ||||||
|   &:hover { |  | ||||||
|     cursor: pointer; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #api-key-copy { |  | ||||||
|   color: #0078e7; |  | ||||||
| } |  | ||||||
| @@ -8,7 +8,7 @@ import threading | |||||||
| import time | import time | ||||||
| import uuid as uuid_builder | import uuid as uuid_builder | ||||||
| from copy import deepcopy | from copy import deepcopy | ||||||
| from os import mkdir, path, unlink | from os import path, unlink | ||||||
| from threading import Lock | from threading import Lock | ||||||
| import re | import re | ||||||
| import requests | import requests | ||||||
| @@ -27,17 +27,18 @@ class ChangeDetectionStore: | |||||||
|     # For when we edit, we should write to disk |     # For when we edit, we should write to disk | ||||||
|     needs_write_urgent = False |     needs_write_urgent = False | ||||||
|  |  | ||||||
|  |     __version_check = True | ||||||
|  |  | ||||||
|     def __init__(self, datastore_path="/datastore", include_default_watches=True, version_tag="0.0.0"): |     def __init__(self, datastore_path="/datastore", include_default_watches=True, version_tag="0.0.0"): | ||||||
|         # Should only be active for docker |         # Should only be active for docker | ||||||
|         # logging.basicConfig(filename='/dev/stdout', level=logging.INFO) |         # logging.basicConfig(filename='/dev/stdout', level=logging.INFO) | ||||||
|         self.needs_write = False |         self.__data = App.model() | ||||||
|         self.datastore_path = datastore_path |         self.datastore_path = datastore_path | ||||||
|         self.json_store_path = "{}/url-watches.json".format(self.datastore_path) |         self.json_store_path = "{}/url-watches.json".format(self.datastore_path) | ||||||
|  |         self.needs_write = False | ||||||
|         self.proxy_list = None |         self.proxy_list = None | ||||||
|  |         self.start_time = time.time() | ||||||
|         self.stop_thread = False |         self.stop_thread = False | ||||||
|  |  | ||||||
|         self.__data = App.model() |  | ||||||
|  |  | ||||||
|         # Base definition for all watchers |         # Base definition for all watchers | ||||||
|         # deepcopy part of #569 - not sure why its needed exactly |         # deepcopy part of #569 - not sure why its needed exactly | ||||||
|         self.generic_definition = deepcopy(Watch.model(datastore_path = datastore_path, default={})) |         self.generic_definition = deepcopy(Watch.model(datastore_path = datastore_path, default={})) | ||||||
| @@ -81,10 +82,13 @@ class ChangeDetectionStore: | |||||||
|         except (FileNotFoundError, json.decoder.JSONDecodeError): |         except (FileNotFoundError, json.decoder.JSONDecodeError): | ||||||
|             if include_default_watches: |             if include_default_watches: | ||||||
|                 print("Creating JSON store at", self.datastore_path) |                 print("Creating JSON store at", self.datastore_path) | ||||||
|  |                 self.add_watch(url='https://news.ycombinator.com/', | ||||||
|  |                                tag='Tech news', | ||||||
|  |                                extras={'fetch_backend': 'html_requests'}) | ||||||
|  |  | ||||||
|                 self.add_watch(url='http://www.quotationspage.com/random.php', tag='test') |                 self.add_watch(url='https://changedetection.io/CHANGELOG.txt', | ||||||
|                 self.add_watch(url='https://news.ycombinator.com/', tag='Tech news') |                                tag='changedetection.io', | ||||||
|                 self.add_watch(url='https://changedetection.io/CHANGELOG.txt', tag='changedetection.io') |                                extras={'fetch_backend': 'html_requests'}) | ||||||
|  |  | ||||||
|         self.__data['version_tag'] = version_tag |         self.__data['version_tag'] = version_tag | ||||||
|  |  | ||||||
| @@ -113,9 +117,7 @@ class ChangeDetectionStore: | |||||||
|             self.__data['settings']['application']['api_access_token'] = secret |             self.__data['settings']['application']['api_access_token'] = secret | ||||||
|  |  | ||||||
|         # Proxy list support - available as a selection in settings when text file is imported |         # Proxy list support - available as a selection in settings when text file is imported | ||||||
|         # CSV list |         proxy_list_file = "{}/proxies.json".format(self.datastore_path) | ||||||
|         # "name, address", or just "name" |  | ||||||
|         proxy_list_file = "{}/proxies.txt".format(self.datastore_path) |  | ||||||
|         if path.isfile(proxy_list_file): |         if path.isfile(proxy_list_file): | ||||||
|             self.import_proxy_list(proxy_list_file) |             self.import_proxy_list(proxy_list_file) | ||||||
|  |  | ||||||
| @@ -158,13 +160,11 @@ class ChangeDetectionStore: | |||||||
|     @property |     @property | ||||||
|     def threshold_seconds(self): |     def threshold_seconds(self): | ||||||
|         seconds = 0 |         seconds = 0 | ||||||
|         mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7} |         for m, n in Watch.mtable.items(): | ||||||
|         minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 60)) |  | ||||||
|         for m, n in mtable.items(): |  | ||||||
|             x = self.__data['settings']['requests']['time_between_check'].get(m) |             x = self.__data['settings']['requests']['time_between_check'].get(m) | ||||||
|             if x: |             if x: | ||||||
|                 seconds += x * n |                 seconds += x * n | ||||||
|         return max(seconds, minimum_seconds_recheck_time) |         return seconds | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def has_unviewed(self): |     def has_unviewed(self): | ||||||
| @@ -246,27 +246,33 @@ class ChangeDetectionStore: | |||||||
|  |  | ||||||
|         return False |         return False | ||||||
|  |  | ||||||
|     def get_val(self, uuid, val): |  | ||||||
|         # Probably their should be dict... |  | ||||||
|         return self.data['watching'][uuid].get(val) |  | ||||||
|  |  | ||||||
|     # Remove a watchs data but keep the entry (URL etc) |     # Remove a watchs data but keep the entry (URL etc) | ||||||
|     def scrub_watch(self, uuid): |     def clear_watch_history(self, uuid): | ||||||
|         import pathlib |         import pathlib | ||||||
|  |  | ||||||
|         self.__data['watching'][uuid].update({'history': {}, 'last_checked': 0, 'last_changed': 0, 'previous_md5': False}) |         self.__data['watching'][uuid].update( | ||||||
|         self.needs_write_urgent = True |             {'last_checked': 0, | ||||||
|  |              'last_viewed': 0, | ||||||
|  |              'previous_md5': False, | ||||||
|  |              'last_notification_error': False, | ||||||
|  |              'last_error': False}) | ||||||
|  |  | ||||||
|         for item in pathlib.Path(self.datastore_path).rglob(uuid+"/*.txt"): |         # JSON Data, Screenshots, Textfiles (history index and snapshots), HTML in the future etc | ||||||
|  |         for item in pathlib.Path(os.path.join(self.datastore_path, uuid)).rglob("*.*"): | ||||||
|             unlink(item) |             unlink(item) | ||||||
|  |  | ||||||
|  |         # Force the attr to recalculate | ||||||
|  |         bump = self.__data['watching'][uuid].history | ||||||
|  |  | ||||||
|  |         self.needs_write_urgent = True | ||||||
|  |  | ||||||
|     def add_watch(self, url, tag="", extras=None, write_to_disk_now=True): |     def add_watch(self, url, tag="", extras=None, write_to_disk_now=True): | ||||||
|  |  | ||||||
|         if extras is None: |         if extras is None: | ||||||
|             extras = {} |             extras = {} | ||||||
|         # should always be str |         # should always be str | ||||||
|         if tag is None or not tag: |         if tag is None or not tag: | ||||||
|             tag='' |             tag = '' | ||||||
|  |  | ||||||
|         # Incase these are copied across, assume it's a reference and deepcopy() |         # Incase these are copied across, assume it's a reference and deepcopy() | ||||||
|         apply_extras = deepcopy(extras) |         apply_extras = deepcopy(extras) | ||||||
| @@ -280,16 +286,33 @@ class ChangeDetectionStore: | |||||||
|                                      headers={'App-Guid': self.__data['app_guid']}) |                                      headers={'App-Guid': self.__data['app_guid']}) | ||||||
|                 res = r.json() |                 res = r.json() | ||||||
|  |  | ||||||
|                 # List of permisable stuff we accept from the wild internet |                 # List of permissible attributes we accept from the wild internet | ||||||
|                 for k in ['url', 'tag', |                 for k in [ | ||||||
|                                    'paused', 'title', |                     'body', | ||||||
|                                    'previous_md5', 'headers', |                     'browser_steps', | ||||||
|                                    'body', 'method', |                     'css_filter', | ||||||
|                                    'ignore_text', 'css_filter', |                     'extract_text', | ||||||
|                                    'subtractive_selectors', 'trigger_text', |                     'extract_title_as_title', | ||||||
|                                    'extract_title_as_title']: |                     'headers', | ||||||
|  |                     'ignore_text', | ||||||
|  |                     'include_filters', | ||||||
|  |                     'method', | ||||||
|  |                     'paused', | ||||||
|  |                     'previous_md5', | ||||||
|  |                     'subtractive_selectors', | ||||||
|  |                     'tag', | ||||||
|  |                     'text_should_not_be_present', | ||||||
|  |                     'title', | ||||||
|  |                     'trigger_text', | ||||||
|  |                     'url', | ||||||
|  |                     'webdriver_js_execute_code', | ||||||
|  |                 ]: | ||||||
|                     if res.get(k): |                     if res.get(k): | ||||||
|                         apply_extras[k] = res[k] |                         if k != 'css_filter': | ||||||
|  |                             apply_extras[k] = res[k] | ||||||
|  |                         else: | ||||||
|  |                             # We renamed the field and made it a list | ||||||
|  |                             apply_extras['include_filters'] = [res['css_filter']] | ||||||
|  |  | ||||||
|             except Exception as e: |             except Exception as e: | ||||||
|                 logging.error("Error fetching metadata for shared watch link", url, str(e)) |                 logging.error("Error fetching metadata for shared watch link", url, str(e)) | ||||||
| @@ -312,27 +335,15 @@ class ChangeDetectionStore: | |||||||
|                     del apply_extras[k] |                     del apply_extras[k] | ||||||
|  |  | ||||||
|             new_watch.update(apply_extras) |             new_watch.update(apply_extras) | ||||||
|             self.__data['watching'][new_uuid]=new_watch |             self.__data['watching'][new_uuid] = new_watch | ||||||
|  |  | ||||||
|         # Get the directory ready |         self.__data['watching'][new_uuid].ensure_data_dir_exists() | ||||||
|         output_path = "{}/{}".format(self.datastore_path, new_uuid) |  | ||||||
|         try: |  | ||||||
|             mkdir(output_path) |  | ||||||
|         except FileExistsError: |  | ||||||
|             print(output_path, "already exists.") |  | ||||||
|  |  | ||||||
|         if write_to_disk_now: |         if write_to_disk_now: | ||||||
|             self.sync_to_json() |             self.sync_to_json() | ||||||
|  |  | ||||||
|         return new_uuid |         return new_uuid | ||||||
|  |  | ||||||
|     def get_screenshot(self, watch_uuid): |  | ||||||
|         output_path = "{}/{}".format(self.datastore_path, watch_uuid) |  | ||||||
|         fname = "{}/last-screenshot.png".format(output_path) |  | ||||||
|         if path.isfile(fname): |  | ||||||
|             return fname |  | ||||||
|  |  | ||||||
|         return False |  | ||||||
|  |  | ||||||
|     def visualselector_data_is_ready(self, watch_uuid): |     def visualselector_data_is_ready(self, watch_uuid): | ||||||
|         output_path = "{}/{}".format(self.datastore_path, watch_uuid) |         output_path = "{}/{}".format(self.datastore_path, watch_uuid) | ||||||
|         screenshot_filename = "{}/last-screenshot.png".format(output_path) |         screenshot_filename = "{}/last-screenshot.png".format(output_path) | ||||||
| @@ -343,17 +354,44 @@ class ChangeDetectionStore: | |||||||
|         return False |         return False | ||||||
|  |  | ||||||
|     # Save as PNG, PNG is larger but better for doing visual diff in the future |     # Save as PNG, PNG is larger but better for doing visual diff in the future | ||||||
|     def save_screenshot(self, watch_uuid, screenshot: bytes): |     def save_screenshot(self, watch_uuid, screenshot: bytes, as_error=False): | ||||||
|         output_path = "{}/{}".format(self.datastore_path, watch_uuid) |         if not self.data['watching'].get(watch_uuid): | ||||||
|         fname = "{}/last-screenshot.png".format(output_path) |             return | ||||||
|         with open(fname, 'wb') as f: |  | ||||||
|  |         if as_error: | ||||||
|  |             target_path = os.path.join(self.datastore_path, watch_uuid, "last-error-screenshot.png") | ||||||
|  |         else: | ||||||
|  |             target_path = os.path.join(self.datastore_path, watch_uuid, "last-screenshot.png") | ||||||
|  |  | ||||||
|  |         self.data['watching'][watch_uuid].ensure_data_dir_exists() | ||||||
|  |  | ||||||
|  |         with open(target_path, 'wb') as f: | ||||||
|             f.write(screenshot) |             f.write(screenshot) | ||||||
|             f.close() |             f.close() | ||||||
|  |  | ||||||
|     def save_xpath_data(self, watch_uuid, data): |         # Make a JPEG that's used in notifications (due to being a smaller size) available | ||||||
|         output_path = "{}/{}".format(self.datastore_path, watch_uuid) |         from PIL import Image | ||||||
|         fname = "{}/elements.json".format(output_path) |         im1 = Image.open(target_path) | ||||||
|         with open(fname, 'w') as f: |         im1.convert('RGB').save(target_path.replace('.png','.jpg'), quality=int(os.getenv("NOTIFICATION_SCREENSHOT_JPG_QUALITY", 75))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     def save_error_text(self, watch_uuid, contents): | ||||||
|  |         if not self.data['watching'].get(watch_uuid): | ||||||
|  |             return | ||||||
|  |         target_path = os.path.join(self.datastore_path, watch_uuid, "last-error.txt") | ||||||
|  |  | ||||||
|  |         with open(target_path, 'w') as f: | ||||||
|  |             f.write(contents) | ||||||
|  |  | ||||||
|  |     def save_xpath_data(self, watch_uuid, data, as_error=False): | ||||||
|  |         if not self.data['watching'].get(watch_uuid): | ||||||
|  |             return | ||||||
|  |         if as_error: | ||||||
|  |             target_path = os.path.join(self.datastore_path, watch_uuid, "elements-error.json") | ||||||
|  |         else: | ||||||
|  |             target_path = os.path.join(self.datastore_path, watch_uuid, "elements.json") | ||||||
|  |  | ||||||
|  |         with open(target_path, 'w') as f: | ||||||
|             f.write(json.dumps(data)) |             f.write(json.dumps(data)) | ||||||
|             f.close() |             f.close() | ||||||
|  |  | ||||||
| @@ -423,20 +461,42 @@ class ChangeDetectionStore: | |||||||
|                     unlink(item) |                     unlink(item) | ||||||
|  |  | ||||||
|     def import_proxy_list(self, filename): |     def import_proxy_list(self, filename): | ||||||
|         import csv |         with open(filename) as f: | ||||||
|         with open(filename, newline='') as f: |             self.proxy_list = json.load(f) | ||||||
|             reader = csv.reader(f, skipinitialspace=True) |             print ("Registered proxy list", list(self.proxy_list.keys())) | ||||||
|             # @todo This loop can could be improved |  | ||||||
|             l = [] |  | ||||||
|             for row in reader: |  | ||||||
|                 if len(row): |  | ||||||
|                     if len(row)>=2: |  | ||||||
|                         l.append(tuple(row[:2])) |  | ||||||
|                     else: |  | ||||||
|                         l.append(tuple([row[0], row[0]])) |  | ||||||
|             self.proxy_list = l if len(l) else None |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     def get_preferred_proxy_for_watch(self, uuid): | ||||||
|  |         """ | ||||||
|  |         Returns the preferred proxy by ID key | ||||||
|  |         :param uuid: UUID | ||||||
|  |         :return: proxy "key" id | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         proxy_id = None | ||||||
|  |         if self.proxy_list is None: | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         # If its a valid one | ||||||
|  |         watch = self.data['watching'].get(uuid) | ||||||
|  |  | ||||||
|  |         if watch.get('proxy') and watch.get('proxy') in list(self.proxy_list.keys()): | ||||||
|  |             return watch.get('proxy') | ||||||
|  |  | ||||||
|  |         # not valid (including None), try the system one | ||||||
|  |         else: | ||||||
|  |             system_proxy_id = self.data['settings']['requests'].get('proxy') | ||||||
|  |             # Is not None and exists | ||||||
|  |             if self.proxy_list.get(system_proxy_id): | ||||||
|  |                 return system_proxy_id | ||||||
|  |  | ||||||
|  |         # Fallback - Did not resolve anything, use the first available | ||||||
|  |         if system_proxy_id is None: | ||||||
|  |             first_default = list(self.proxy_list)[0] | ||||||
|  |             return first_default | ||||||
|  |  | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     # Run all updates |     # Run all updates | ||||||
|     # IMPORTANT - Each update could be run even when they have a new install and the schema is correct |     # IMPORTANT - Each update could be run even when they have a new install and the schema is correct | ||||||
|     #             So therefor - each `update_n` should be very careful about checking if it needs to actually run |     #             So therefor - each `update_n` should be very careful about checking if it needs to actually run | ||||||
| @@ -507,3 +567,58 @@ class ChangeDetectionStore: | |||||||
|                 # But we should set it back to a empty dict so we don't break if this schema runs on an earlier version. |                 # But we should set it back to a empty dict so we don't break if this schema runs on an earlier version. | ||||||
|                 # In the distant future we can remove this entirely |                 # In the distant future we can remove this entirely | ||||||
|                 self.data['watching'][uuid]['history'] = {} |                 self.data['watching'][uuid]['history'] = {} | ||||||
|  |  | ||||||
|  |     # We incorrectly stored last_changed when there was not a change, and then confused the output list table | ||||||
|  |     def update_3(self): | ||||||
|  |         # see https://github.com/dgtlmoon/changedetection.io/pull/835 | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |     # `last_changed` not needed, we pull that information from the history.txt index | ||||||
|  |     def update_4(self): | ||||||
|  |         for uuid, watch in self.data['watching'].items(): | ||||||
|  |             try: | ||||||
|  |                 # Remove it from the struct | ||||||
|  |                 del(watch['last_changed']) | ||||||
|  |             except: | ||||||
|  |                 continue | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |     def update_5(self): | ||||||
|  |         # If the watch notification body, title look the same as the global one, unset it, so the watch defaults back to using the main settings | ||||||
|  |         # In other words - the watch notification_title and notification_body are not needed if they are the same as the default one | ||||||
|  |         current_system_body = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n ")) | ||||||
|  |         current_system_title = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n ")) | ||||||
|  |         for uuid, watch in self.data['watching'].items(): | ||||||
|  |             try: | ||||||
|  |                 watch_body = watch.get('notification_body', '') | ||||||
|  |                 if watch_body and watch_body.translate(str.maketrans('', '', "\r\n ")) == current_system_body: | ||||||
|  |                     # Looks the same as the default one, so unset it | ||||||
|  |                     watch['notification_body'] = None | ||||||
|  |  | ||||||
|  |                 watch_title = watch.get('notification_title', '') | ||||||
|  |                 if watch_title and watch_title.translate(str.maketrans('', '', "\r\n ")) == current_system_title: | ||||||
|  |                     # Looks the same as the default one, so unset it | ||||||
|  |                     watch['notification_title'] = None | ||||||
|  |             except Exception as e: | ||||||
|  |                 continue | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     # We incorrectly used common header overrides that should only apply to Requests | ||||||
|  |     # These are now handled in content_fetcher::html_requests and shouldnt be passed to Playwright/Selenium | ||||||
|  |     def update_7(self): | ||||||
|  |         # These were hard-coded in early versions | ||||||
|  |         for v in ['User-Agent', 'Accept', 'Accept-Encoding', 'Accept-Language']: | ||||||
|  |             if self.data['settings']['headers'].get(v): | ||||||
|  |                 del self.data['settings']['headers'][v] | ||||||
|  |  | ||||||
|  |     # Convert filters to a list of filters css_filter -> include_filters | ||||||
|  |     def update_8(self): | ||||||
|  |         for uuid, watch in self.data['watching'].items(): | ||||||
|  |             try: | ||||||
|  |                 existing_filter = watch.get('css_filter', '') | ||||||
|  |                 if existing_filter: | ||||||
|  |                     watch['include_filters'] = [existing_filter] | ||||||
|  |             except: | ||||||
|  |                 continue | ||||||
|  |         return | ||||||
| @@ -1,39 +1,43 @@ | |||||||
|  |  | ||||||
| {% from '_helpers.jinja' import render_field %} | {% from '_helpers.jinja' import render_field %} | ||||||
|  |  | ||||||
| {% macro render_common_settings_form(form, current_base_url, emailprefix) %} | {% macro render_common_settings_form(form, emailprefix, settings_application) %} | ||||||
|                         <div class="pure-control-group"> |                         <div class="pure-control-group"> | ||||||
|                             {{ render_field(form.notification_urls, rows=5, placeholder="Examples: |                             {{ render_field(form.notification_urls, rows=5, placeholder="Examples: | ||||||
|     Gitter - gitter://token/room |     Gitter - gitter://token/room | ||||||
|     Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail |     Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail | ||||||
|     AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo |     AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo | ||||||
|     SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com", class="notification-urls") |     SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com", | ||||||
|  |     class="notification-urls" ) | ||||||
|                             }} |                             }} | ||||||
|                             <div class="pure-form-message-inline"> |                             <div class="pure-form-message-inline"> | ||||||
|                               <ul> |                               <ul> | ||||||
|                                 <li>Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.</li> |                                 <li>Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.</li> | ||||||
|                                 <li><code>discord://</code> only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li> |                                 <li><code>discord://</code> only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li> | ||||||
|                                 <li><code>tgram://</code> bots cant send messages to other bots, so you should specify chat ID of non-bot user.</li> |                                 <li><code>tgram://</code> bots cant send messages to other bots, so you should specify chat ID of non-bot user.</li> | ||||||
|                                 <li>Go here for <a href="{{url_for('notification_logs')}}">notification debug logs</a></li> |                                 <li><code>tgram://</code> only supports very limited HTML and can fail when extra tags are sent, <a href="https://core.telegram.org/bots/api#html-style">read more here</a> (or use plaintext/markdown format)</li> | ||||||
|                               </ul> |                               </ul> | ||||||
|                             </div> |                             </div> | ||||||
|                             <br/> |                             <div class="notifications-wrapper"> | ||||||
|                             <a id="send-test-notification" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Send test notification</a> |                               <a id="send-test-notification" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Send test notification</a> | ||||||
| {% if emailprefix %} |                             {% if emailprefix %} | ||||||
|                             <a id="add-email-helper" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Add email</a> |                               <a id="add-email-helper" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Add email</a> | ||||||
| {% endif %} |                             {% endif %} | ||||||
|  |                               <a href="{{url_for('notification_logs')}}" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Notification debug logs</a> | ||||||
|  |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|                         <div id="notification-customisation" class="pure-control-group"> |                         <div id="notification-customisation" class="pure-control-group"> | ||||||
|                             <div class="pure-control-group"> |                             <div class="pure-control-group"> | ||||||
|                                 {{ render_field(form.notification_title, class="m-d notification-title") }} |                                 {{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }} | ||||||
|                                 <span class="pure-form-message-inline">Title for all notifications</span> |                                 <span class="pure-form-message-inline">Title for all notifications</span> | ||||||
|                             </div> |                             </div> | ||||||
|                             <div class="pure-control-group"> |                             <div class="pure-control-group"> | ||||||
|                                 {{ render_field(form.notification_body , rows=5, class="notification-body") }} |                                 {{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }} | ||||||
|                                 <span class="pure-form-message-inline">Body for all notifications</span> |                                 <span class="pure-form-message-inline">Body for all notifications</span> | ||||||
|                             </div> |                             </div> | ||||||
|                             <div class="pure-control-group"> |                             <div class="pure-control-group"> | ||||||
|                                 {{ render_field(form.notification_format , rows=5, class="notification-format") }} |                             <!-- unsure --> | ||||||
|  |                                 {{ render_field(form.notification_format , class="notification-format") }} | ||||||
|                                 <span class="pure-form-message-inline">Format for all notifications</span> |                                 <span class="pure-form-message-inline">Format for all notifications</span> | ||||||
|                             </div> |                             </div> | ||||||
|                             <div class="pure-controls"> |                             <div class="pure-controls"> | ||||||
| @@ -93,7 +97,7 @@ | |||||||
|                                 </table> |                                 </table> | ||||||
|                                 <br/> |                                 <br/> | ||||||
|                                 URLs generated by changedetection.io (such as <code>{diff_url}</code>) require the <code>BASE_URL</code> environment variable set.<br/> |                                 URLs generated by changedetection.io (such as <code>{diff_url}</code>) require the <code>BASE_URL</code> environment variable set.<br/> | ||||||
|                                 Your <code>BASE_URL</code> var is currently "{{current_base_url}}" |                                 Your <code>BASE_URL</code> var is currently "{{settings_application['current_base_url']}}" | ||||||
|                             </span> |                             </span> | ||||||
|                             </div> |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								changedetectionio/templates/_pagination.jinja
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | |||||||
|  | {% macro pagination(sorted_watches, total_per_page, current_page) %} | ||||||
|  |   {{ sorted_watches|length }} | ||||||
|  |  | ||||||
|  |   {% for row in sorted_watches|batch(total_per_page, ' ') %} | ||||||
|  |     {{ loop.index}} | ||||||
|  |   {% endfor %} | ||||||
|  | {% endmacro %} | ||||||
| @@ -1,110 +1,152 @@ | |||||||
| <!doctype html> | <!DOCTYPE html> | ||||||
| <html lang="en"> | <html lang="en" data-darkmode="{{ dark_mode|lower }}"> | ||||||
| <head> |  | ||||||
|     <meta charset="utf-8"> |   <head> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <meta charset="utf-8"/> | ||||||
|     <meta name="description" content="Self hosted website change detection."> |     <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> |     <title>Change Detection{{extra_title}}</title> | ||||||
|     <link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag %}- {{active_tag}}{% endif %}" href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}" /> |     <link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag %}- {{active_tag}}{% endif %}" href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}"/> | ||||||
|     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}"> |     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}"/> | ||||||
|     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}"> |     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}"/> | ||||||
|     {% if extra_stylesheets %} |     {% if extra_stylesheets %} | ||||||
|         {% for m in extra_stylesheets %} |       {% for m in extra_stylesheets %} | ||||||
|         <link rel="stylesheet" href="{{ m }}?ver=1000"> |         <link rel="stylesheet" href="{{ m }}?ver=1000"/> | ||||||
|         {% endfor %} |       {% endfor %} | ||||||
|     {% endif %} |     {% endif %} | ||||||
|  |  | ||||||
|  |     <link rel="apple-touch-icon" sizes="180x180" href="{{url_for('static_content', group='favicons', filename='apple-touch-icon.png')}}"/> | ||||||
|  |     <link rel="icon" type="image/png" sizes="32x32" href="{{url_for('static_content', group='favicons', filename='favicon-32x32.png')}}"/> | ||||||
|  |     <link rel="icon" type="image/png" sizes="16x16" href="{{url_for('static_content', group='favicons', filename='favicon-16x16.png')}}"/> | ||||||
|  |     <link rel="manifest" href="{{url_for('static_content', group='favicons', filename='site.webmanifest')}}"/> | ||||||
|  |     <link rel="mask-icon" href="{{url_for('static_content', group='favicons', filename='safari-pinned-tab.svg')}}" color="#5bbad5"/> | ||||||
|  |     <link rel="shortcut icon" href="{{url_for('static_content', group='favicons', filename='favicon.ico')}}"/> | ||||||
|  |     <meta name="msapplication-TileColor" content="#da532c"/> | ||||||
|  |     <meta name="msapplication-config" content="favicons/browserconfig.xml"/> | ||||||
|  |     <meta name="theme-color" content="#ffffff"/> | ||||||
|  |  | ||||||
|     <style> |     <style> | ||||||
|     body::before { |       body::before { | ||||||
|         background-image: url({{url_for('static_content', group='images', filename='gradient-border.png')}}); |         background-image: url({{url_for('static_content', group='images', filename='gradient-border.png') }}); | ||||||
|     } |       } | ||||||
|     </style> |     </style> | ||||||
|     <script type="text/javascript" src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> |     <script type="text/javascript" src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> | ||||||
|  |   </head> | ||||||
|  |  | ||||||
| </head> |   <body> | ||||||
|  |     <div class="header"> | ||||||
| <body> |       <div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed" id="nav-menu"> | ||||||
|  |  | ||||||
| <div class="header"> |  | ||||||
|  |  | ||||||
|     <div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed" id="nav-menu"> |  | ||||||
|         {% if has_password and not current_user.is_authenticated %} |         {% if has_password and not current_user.is_authenticated %} | ||||||
|             <a class="pure-menu-heading" href="https://github.com/dgtlmoon/changedetection.io" rel="noopener"><strong>Change</strong>Detection.io</a> |           <a class="pure-menu-heading" href="https://github.com/dgtlmoon/changedetection.io" rel="noopener"> | ||||||
|  |             <strong>Change</strong>Detection.io</a> | ||||||
|         {% else %} |         {% else %} | ||||||
|             <a class="pure-menu-heading" href="{{url_for('index')}}"><strong>Change</strong>Detection.io</a> |           <a class="pure-menu-heading" href="{{url_for('index')}}"> | ||||||
|  |             <strong>Change</strong>Detection.io</a> | ||||||
|         {% endif %} |         {% endif %} | ||||||
|         {% if current_diff_url %} |         {% if current_diff_url %} | ||||||
|         <a class=current-diff-url href="{{ current_diff_url }}"><span style="max-width: 30%; overflow: hidden;">{{ current_diff_url }}</span></a> |           <a class="current-diff-url" href="{{ current_diff_url }}"> | ||||||
|  |             <span style="max-width: 30%; overflow: hidden">{{ current_diff_url }}</span></a> | ||||||
|         {% else %} |         {% else %} | ||||||
|         {% if new_version_available and not (has_password and not current_user.is_authenticated) %} |           {% if new_version_available and not(has_password and not current_user.is_authenticated) %} | ||||||
|         <span id="new-version-text" class="pure-menu-heading"><a href="https://github.com/dgtlmoon/changedetection.io">A new version is available</a></span> |             <span id="new-version-text" class="pure-menu-heading"> | ||||||
|         {% endif %} |               <a href="https://github.com/dgtlmoon/changedetection.io">A new version is available</a> | ||||||
|  |             </span> | ||||||
|  |           {% endif %} | ||||||
|         {% endif %} |         {% endif %} | ||||||
|  |  | ||||||
|         <ul class="pure-menu-list"  id="top-right-menu"> |         <ul class="pure-menu-list" id="top-right-menu"> | ||||||
|         {% if current_user.is_authenticated or not has_password %} |           {% if current_user.is_authenticated or not has_password %} | ||||||
|             {% if not current_diff_url %} |             {% if not | ||||||
|             <li class="pure-menu-item"> |             current_diff_url %} | ||||||
|  |               <li class="pure-menu-item"> | ||||||
|                 <a href="{{ url_for('settings_page')}}" class="pure-menu-link">SETTINGS</a> |                 <a href="{{ url_for('settings_page')}}" class="pure-menu-link">SETTINGS</a> | ||||||
|             </li> |               </li> | ||||||
|             <li class="pure-menu-item"> |               <li class="pure-menu-item"> | ||||||
|                 <a href="{{ url_for('import_page')}}" class="pure-menu-link">IMPORT</a> |                 <a href="{{ url_for('import_page')}}" class="pure-menu-link">IMPORT</a> | ||||||
|             </li> |               </li> | ||||||
|             <li class="pure-menu-item"> |               <li class="pure-menu-item"> | ||||||
|                 <a href="{{ url_for('get_backup')}}" class="pure-menu-link">BACKUP</a> |                 <a href="{{ url_for('get_backup')}}" class="pure-menu-link">BACKUP</a> | ||||||
|             </li> |               </li> | ||||||
|             {% else %} |             {% else %} | ||||||
|             <li class="pure-menu-item"> |               <li class="pure-menu-item"> | ||||||
|                 <a href="{{ url_for('edit_page', uuid=uuid, next='diff') }}" class="pure-menu-link">EDIT</a> |                 <a href="{{ url_for('edit_page', uuid=uuid, next='diff') }}" class="pure-menu-link">EDIT</a> | ||||||
|             </li> |               </li> | ||||||
|             {% endif %} |             {% endif %} | ||||||
|         {% else %} |           {% else %} | ||||||
|             <li class="pure-menu-item"> |             <li class="pure-menu-item"> | ||||||
|                 <a class="pure-menu-link" href="https://github.com/dgtlmoon/changedetection.io">Website Change Detection and Notification.</a> |               <a class="pure-menu-link" href="https://github.com/dgtlmoon/changedetection.io">Website Change Detection and Notification.</a> | ||||||
|             </li> |             </li> | ||||||
|         {% endif %} |           {% endif %} | ||||||
|  |           {% if current_user.is_authenticated %} | ||||||
|         {% if current_user.is_authenticated %} |             <li class="pure-menu-item"> | ||||||
|             <li class="pure-menu-item"><a href="{{url_for('logout')}}" class="pure-menu-link">LOG OUT</a></li> |               <a href="{{url_for('logout')}}" class="pure-menu-link">LOG OUT</a> | ||||||
|         {% endif %} |             </li> | ||||||
|             <li class="pure-menu-item"><a class="github-link" href="https://github.com/dgtlmoon/changedetection.io"> |           {% endif %} | ||||||
|                 <svg class="octicon octicon-mark-github v-align-middle" height="32" viewBox="0 0 16 16" |           <li class="pure-menu-item"> | ||||||
|                      version="1.1" |             {% if dark_mode %} | ||||||
|                      width="32" aria-hidden="true"> |             {% set darkClass = 'dark' %} | ||||||
|                     <path fill-rule="evenodd" |             {% endif %} | ||||||
|                           d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path> |             <button class="toggle-theme {{darkClass}}" type="button"> | ||||||
|                 </svg> |               <span class="visually-hidden">Toggle light/dark mode</span> | ||||||
|             </a></li> |               <span class="icon-light"> | ||||||
|  |                 {% include "svgs/light-mode-toggle-icon.svg" %} | ||||||
|  |               </span> | ||||||
|  |               <span class="icon-dark"> | ||||||
|  |                 {% include "svgs/dark-mode-toggle-icon.svg" %} | ||||||
|  |               </span> | ||||||
|  |             </button> | ||||||
|  |           </li> | ||||||
|  |           <li class="pure-menu-item"> | ||||||
|  |             <a class="github-link" href="https://github.com/dgtlmoon/changedetection.io"> | ||||||
|  |               {% include "svgs/github.svg" %} | ||||||
|  |             </a> | ||||||
|  |           </li> | ||||||
|         </ul> |         </ul> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
| </div> |     {% if hosted_sticky %} | ||||||
| {% if hosted_sticky %}<div class="sticky-tab" id="hosted-sticky"><a href="https://lemonade.changedetection.io/start?ref={{guid}}">Let us host your instance!</a></div>{% endif %} |       <div class="sticky-tab" id="hosted-sticky"> | ||||||
| {% if left_sticky %}<div class="sticky-tab" id="left-sticky"><a href="{{url_for('preview_page', uuid=uuid)}}">Show current snapshot</a></div> {% endif %} |         <a href="https://lemonade.changedetection.io/start?ref={{guid}}">Let us host your instance!</a> | ||||||
| {% if right_sticky %}<div class="sticky-tab" id="right-sticky">{{ right_sticky }}</div> {% endif %} |       </div> | ||||||
| <section class="content"> |     {% endif %} | ||||||
|     <header> |     {% if left_sticky %} | ||||||
|  |       <div class="sticky-tab" id="left-sticky"> | ||||||
|  |         <a href="{{url_for('preview_page', uuid=uuid)}}">Show current snapshot</a> | ||||||
|  |       </div> | ||||||
|  |     {% endif %} | ||||||
|  |     {% if right_sticky %} | ||||||
|  |       <div class="sticky-tab" id="right-sticky">{{ right_sticky }}</div> | ||||||
|  |     {% endif %} | ||||||
|  |     <section class="content"> | ||||||
|  |       <header> | ||||||
|         {% block header %}{% endblock %} |         {% block header %}{% endblock %} | ||||||
|     </header> |       </header> | ||||||
|  |  | ||||||
|     {% with messages = get_flashed_messages(with_categories=true) %} |       {% with messages = get_flashed_messages(with_categories = true) %} | ||||||
|       {% if messages %} |       {% if | ||||||
|         <ul class=messages> |       messages %} | ||||||
|         {% for category, message in messages %} |         <ul class="messages"> | ||||||
|           <li class="{{ category }}">{{ message }}</li> |           {% for category, message in messages %} | ||||||
|         {% endfor %} |             <li class="{{ category }}">{{ message }}</li> | ||||||
|  |           {% endfor %} | ||||||
|         </ul> |         </ul> | ||||||
|       {% endif %} |       {% endif %} | ||||||
|     {% endwith %} |       {% endwith %} | ||||||
|  |       {% if session['share-link'] %} | ||||||
|     {% if session['share-link'] %} |  | ||||||
|         <ul class="messages with-share-link"> |         <ul class="messages with-share-link"> | ||||||
|           <li class="message">Share this link: <span id="share-link">{{ session['share-link'] }}</span> <img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='copy.svg')}}" /></li> |           <li class="message"> | ||||||
|  |             Share this link: | ||||||
|  |             <span id="share-link">{{ session['share-link'] }}</span> | ||||||
|  |             <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}"/> | ||||||
|  |           </li> | ||||||
|         </ul> |         </ul> | ||||||
|     {% endif %} |       {% endif %} | ||||||
|  |       {% block content %}{% endblock %} | ||||||
|  |     </section> | ||||||
|  |     <script | ||||||
|  |       type="text/javascript" | ||||||
|  |       src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" | ||||||
|  |       defer></script> | ||||||
|  |   </body> | ||||||
|  |  | ||||||
|     {% block content %} |  | ||||||
|  |  | ||||||
|     {% endblock %} |  | ||||||
| </section> |  | ||||||
|  |  | ||||||
| </body> |  | ||||||
| </html> | </html> | ||||||
|   | |||||||
							
								
								
									
										49
									
								
								changedetectionio/templates/clear_all_history.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,49 @@ | |||||||
|  | {% extends 'base.html' %} {% block content %} | ||||||
|  | <div class="edit-form"> | ||||||
|  |   <div class="box-wrap inner"> | ||||||
|  |     <form | ||||||
|  |       class="pure-form pure-form-stacked" | ||||||
|  |       action="{{url_for('clear_all_history')}}" | ||||||
|  |       method="POST" | ||||||
|  |     > | ||||||
|  |       <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> | ||||||
|  |       <fieldset> | ||||||
|  |         <div class="pure-control-group"> | ||||||
|  |           This will remove version history (snapshots) for ALL watches, but keep | ||||||
|  |           your list of URLs! <br /> | ||||||
|  |           You may like to use the <strong>BACKUP</strong> link first.<br /> | ||||||
|  |         </div> | ||||||
|  |         <br /> | ||||||
|  |         <div class="pure-control-group"> | ||||||
|  |           <label for="confirmtext">Confirmation text</label> | ||||||
|  |           <input | ||||||
|  |             type="text" | ||||||
|  |             id="confirmtext" | ||||||
|  |             required="" | ||||||
|  |             name="confirmtext" | ||||||
|  |             value="" | ||||||
|  |             size="10" | ||||||
|  |           /> | ||||||
|  |           <span class="pure-form-message-inline" | ||||||
|  |             >Type in the word <strong>clear</strong> to confirm that you | ||||||
|  |             understand.</span | ||||||
|  |           > | ||||||
|  |         </div> | ||||||
|  |         <br /> | ||||||
|  |         <div class="pure-control-group"> | ||||||
|  |           <button type="submit" class="pure-button pure-button-primary"> | ||||||
|  |             Clear History! | ||||||
|  |           </button> | ||||||
|  |         </div> | ||||||
|  |         <br /> | ||||||
|  |         <div class="pure-control-group"> | ||||||
|  |           <a href="{{url_for('index')}}" class="pure-button button-cancel" | ||||||
|  |             >Cancel</a | ||||||
|  |           > | ||||||
|  |         </div> | ||||||
|  |       </fieldset> | ||||||
|  |     </form> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
| @@ -1,6 +1,14 @@ | |||||||
| {% extends 'base.html' %} | {% extends 'base.html' %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  | <script> | ||||||
|  |     const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}"; | ||||||
|  |     {% if last_error_screenshot %} | ||||||
|  |     const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||||
|  |     {% endif %} | ||||||
|  | </script> | ||||||
|  | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> | ||||||
|  |  | ||||||
| <div id="settings"> | <div id="settings"> | ||||||
|     <h1>Differences</h1> |     <h1>Differences</h1> | ||||||
|     <form class="pure-form " action="" method="GET"> |     <form class="pure-form " action="" method="GET"> | ||||||
| @@ -13,11 +21,14 @@ | |||||||
|  |  | ||||||
|             <label for="diffChars" class="pure-checkbox"> |             <label for="diffChars" class="pure-checkbox"> | ||||||
|                 <input type="radio" name="diff_type" id="diffChars" value="diffChars"/> Chars</label> |                 <input type="radio" name="diff_type" id="diffChars" value="diffChars"/> Chars</label> | ||||||
|  |             <!-- @todo - when mimetype is JSON, select this by default? --> | ||||||
|  |             <label for="diffJson" class="pure-checkbox"> | ||||||
|  |                 <input type="radio" name="diff_type" id="diffJson" value="diffJson" /> JSON</label> | ||||||
|  |  | ||||||
|             {% if versions|length >= 1 %} |             {% if versions|length >= 1 %} | ||||||
|             <label for="diff-version">Compare newest (<span id="current-v-date"></span>) with</label> |             <label for="diff-version">Compare newest (<span id="current-v-date"></span>) with</label> | ||||||
|             <select id="diff-version" name="previous_version"> |             <select id="diff-version" name="previous_version"> | ||||||
|                 {% for version in versions %} |                 {% for version in versions|reverse %} | ||||||
|                 <option value="{{version}}" {% if version== current_previous_version %} selected="" {% endif %}> |                 <option value="{{version}}" {% if version== current_previous_version %} selected="" {% endif %}> | ||||||
|                     {{version}} |                     {{version}} | ||||||
|                 </option> |                 </option> | ||||||
| @@ -29,6 +40,11 @@ | |||||||
|     </form> |     </form> | ||||||
|     <del>Removed text</del> |     <del>Removed text</del> | ||||||
|     <ins>Inserted Text</ins> |     <ins>Inserted Text</ins> | ||||||
|  |     <span> | ||||||
|  |         <!-- https://github.com/kpdecker/jsdiff/issues/389 ? --> | ||||||
|  |         <label for="ignoreWhitespace" class="pure-checkbox" id="label-diff-ignorewhitespace"> | ||||||
|  |             <input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace"/> Ignore Whitespace</label> | ||||||
|  |     </span> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <div id="diff-jump"> | <div id="diff-jump"> | ||||||
| @@ -38,14 +54,31 @@ | |||||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||||
| <div class="tabs"> | <div class="tabs"> | ||||||
|     <ul> |     <ul> | ||||||
|         <li class="tab" id="default-tab"><a href="#text">Text</a></li> |         {% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %} | ||||||
|  |         {% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">Error Screenshot</a></li> {% endif %} | ||||||
|  |         <li class="tab" id=""><a href="#text">Text</a></li> | ||||||
|  |         <li class="tab" id="screenshot-tab"><a href="#screenshot">Screenshot</a></li> | ||||||
|     </ul> |     </ul> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <div id="diff-ui"> | <div id="diff-ui"> | ||||||
|  |     <div class="tab-pane-inner" id="error-text"> | ||||||
|  |         <div class="snapshot-age error">{{watch_a.error_text_ctime|format_seconds_ago}} seconds ago</div> | ||||||
|  |         <pre> | ||||||
|  |             {{ last_error_text }} | ||||||
|  |         </pre> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="tab-pane-inner" id="error-screenshot"> | ||||||
|  |         <div class="snapshot-age error">{{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div> | ||||||
|  |         <img id="error-screenshot-img"  style="max-width: 80%" alt="Current error-ing screenshot from most recent request"/> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|      <div class="tab-pane-inner" id="text"> |      <div class="tab-pane-inner" id="text"> | ||||||
|          <div class="tip">Pro-tip: Use <strong>show current snapshot</strong> tab to visualise what will be ignored. |          <div class="tip">Pro-tip: Use <strong>show current snapshot</strong> tab to visualise what will be ignored. | ||||||
|          </div> |          </div> | ||||||
|  |          <div class="snapshot-age">{{watch_a.snapshot_text_ctime|format_timestamp_timeago}}</div> | ||||||
|  |  | ||||||
|          <table> |          <table> | ||||||
|              <tbody> |              <tbody> | ||||||
|              <tr> |              <tr> | ||||||
| @@ -60,125 +93,29 @@ | |||||||
|          </table> |          </table> | ||||||
|          Diff algorithm from the amazing <a href="https://github.com/kpdecker/jsdiff">github.com/kpdecker/jsdiff</a> |          Diff algorithm from the amazing <a href="https://github.com/kpdecker/jsdiff">github.com/kpdecker/jsdiff</a> | ||||||
|      </div> |      </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> | ||||||
|  |          {% if is_html_webdriver %} | ||||||
|  |            {% if screenshot %} | ||||||
|  |             <div class="snapshot-age">{{watch_a.snapshot_screenshot_ctime|format_timestamp_timeago}}</div> | ||||||
|  |             <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/> | ||||||
|  |            {% else %} | ||||||
|  |               No screenshot available just yet! Try rechecking the page. | ||||||
|  |            {% endif %} | ||||||
|  |          {% else %} | ||||||
|  |            <strong>Screenshot requires Playwright/WebDriver enabled</strong> | ||||||
|  |          {% endif %} | ||||||
|  |      </div> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|  | <script> | ||||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff.js')}}"></script> |     const newest_version_timestamp = {{newest_version_timestamp}}; | ||||||
|  |  | ||||||
| <script defer=""> |  | ||||||
|  |  | ||||||
|  |  | ||||||
| var a = document.getElementById('a'); |  | ||||||
| var b = document.getElementById('b'); |  | ||||||
| var result = document.getElementById('result'); |  | ||||||
|  |  | ||||||
| function changed() { |  | ||||||
| 	var diff = JsDiff[window.diffType](a.textContent, b.textContent); |  | ||||||
| 	var fragment = document.createDocumentFragment(); |  | ||||||
| 	for (var i=0; i < diff.length; i++) { |  | ||||||
|  |  | ||||||
| 		if (diff[i].added && diff[i + 1] && diff[i + 1].removed) { |  | ||||||
| 			var swap = diff[i]; |  | ||||||
| 			diff[i] = diff[i + 1]; |  | ||||||
| 			diff[i + 1] = swap; |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var node; |  | ||||||
| 		if (diff[i].removed) { |  | ||||||
| 			node = document.createElement('del'); |  | ||||||
| 			node.classList.add("change"); |  | ||||||
| 			node.appendChild(document.createTextNode(diff[i].value)); |  | ||||||
|  |  | ||||||
| 		} else if (diff[i].added) { |  | ||||||
| 			node = document.createElement('ins'); |  | ||||||
| 			node.classList.add("change"); |  | ||||||
| 			node.appendChild(document.createTextNode(diff[i].value)); |  | ||||||
| 		} else { |  | ||||||
| 			node = document.createTextNode(diff[i].value); |  | ||||||
| 		} |  | ||||||
| 		fragment.appendChild(node); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	result.textContent = ''; |  | ||||||
| 	result.appendChild(fragment); |  | ||||||
|  |  | ||||||
| 	// Jump at start |  | ||||||
| 	inputs.current=0; |  | ||||||
|     next_diff(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| window.onload = function() { |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     /* Convert what is options from UTC time.time() to local browser time */ |  | ||||||
|     var diffList=document.getElementById("diff-version"); |  | ||||||
|     if (typeof(diffList) != 'undefined' && diffList != null) { |  | ||||||
|         for (var option of diffList.options) { |  | ||||||
|           var dateObject = new Date(option.value*1000); |  | ||||||
|           option.label=dateObject.toLocaleString(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /* Set current version date as local time in the browser also */ |  | ||||||
|     var current_v = document.getElementById("current-v-date"); |  | ||||||
|     var dateObject = new Date({{ newest_version_timestamp }}*1000); |  | ||||||
|     current_v.innerHTML=dateObject.toLocaleString(); |  | ||||||
|  |  | ||||||
|  |  | ||||||
| 	onDiffTypeChange(document.querySelector('#settings [name="diff_type"]:checked')); |  | ||||||
| 	changed(); |  | ||||||
|  |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| a.onpaste = a.onchange = |  | ||||||
| b.onpaste = b.onchange = changed; |  | ||||||
|  |  | ||||||
| if ('oninput' in a) { |  | ||||||
| 	a.oninput = b.oninput = changed; |  | ||||||
| } else { |  | ||||||
| 	a.onkeyup = b.onkeyup = changed; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function onDiffTypeChange(radio) { |  | ||||||
| 	window.diffType = radio.value; |  | ||||||
| // Not necessary  |  | ||||||
| //	document.title = "Diff " + radio.value.slice(4); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| var radio = document.getElementsByName('diff_type'); |  | ||||||
| for (var i = 0; i < radio.length; i++) { |  | ||||||
| 	radio[i].onchange = function(e) { |  | ||||||
| 		onDiffTypeChange(e.target); |  | ||||||
| 		changed(); |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| var inputs = document.getElementsByClassName('change'); |  | ||||||
| inputs.current=0; |  | ||||||
|  |  | ||||||
|  |  | ||||||
| function next_diff() { |  | ||||||
|  |  | ||||||
|     var element = inputs[inputs.current]; |  | ||||||
|     var headerOffset = 80; |  | ||||||
|     var elementPosition = element.getBoundingClientRect().top; |  | ||||||
|     var offsetPosition = elementPosition - headerOffset +  window.scrollY; |  | ||||||
|  |  | ||||||
|     window.scrollTo({ |  | ||||||
|          top: offsetPosition, |  | ||||||
|          behavior: "smooth" |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     inputs.current++; |  | ||||||
|     if(inputs.current >= inputs.length) { |  | ||||||
|       inputs.current=0; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| </script> | </script> | ||||||
|  | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff.min.js')}}"></script> | ||||||
|  |  | ||||||
|  | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-render.js')}}"></script> | ||||||
|  |  | ||||||
|  |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
| @@ -7,24 +7,34 @@ | |||||||
|     const notification_base_url="{{url_for('ajax_callback_send_notification_test')}}"; |     const notification_base_url="{{url_for('ajax_callback_send_notification_test')}}"; | ||||||
|     const watch_visual_selector_data_url="{{url_for('static_content', group='visual_selector_data', filename=uuid)}}"; |     const watch_visual_selector_data_url="{{url_for('static_content', group='visual_selector_data', filename=uuid)}}"; | ||||||
|     const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}"; |     const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}"; | ||||||
|  |     const playwright_enabled={% if playwright_enabled %} true {% else %} false {% endif %}; | ||||||
|  |  | ||||||
| {% if emailprefix %} | {% if emailprefix %} | ||||||
|     const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}'); |     const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}'); | ||||||
| {% endif %} | {% endif %} | ||||||
|  |  | ||||||
|  |     const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}'); | ||||||
|  |     const browser_steps_sync_url="{{url_for('browser_steps.browsersteps_ui_update', uuid=uuid)}}"; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script> | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script> | ||||||
|  | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script> | ||||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> | ||||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script> | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script> | ||||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script> | {% if playwright_enabled %} | ||||||
|  | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='browser-steps.js')}}" defer></script> | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
| <div class="edit-form monospaced-textarea"> | <div class="edit-form monospaced-textarea"> | ||||||
|  |  | ||||||
|     <div class="tabs collapsable"> |     <div class="tabs collapsable"> | ||||||
|         <ul> |         <ul> | ||||||
|             <li class="tab" id="default-tab"><a href="#general">General</a></li> |             <li class="tab" id=""><a href="#general">General</a></li> | ||||||
|             <li class="tab"><a href="#request">Request</a></li> |             <li class="tab"><a href="#request">Request</a></li> | ||||||
|             <li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Selector</a></li> |             {% if playwright_enabled %} | ||||||
|  |             <li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li> | ||||||
|  |             {% endif %} | ||||||
|  |             <li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li> | ||||||
|             <li class="tab"><a href="#filters-and-triggers">Filters & Triggers</a></li> |             <li class="tab"><a href="#filters-and-triggers">Filters & Triggers</a></li> | ||||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> |             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||||
|         </ul> |         </ul> | ||||||
| @@ -32,14 +42,15 @@ | |||||||
|  |  | ||||||
|     <div class="box-wrap inner"> |     <div class="box-wrap inner"> | ||||||
|         <form class="pure-form pure-form-stacked" |         <form class="pure-form pure-form-stacked" | ||||||
|               action="{{ url_for('edit_page', uuid=uuid, next = request.args.get('next') ) }}" method="POST"> |               action="{{ url_for('edit_page', uuid=uuid, next = request.args.get('next'), unpause_on_save = request.args.get('unpause_on_save')) }}" method="POST"> | ||||||
|              <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> |              <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||||
|  |  | ||||||
|             <div class="tab-pane-inner" id="general"> |             <div class="tab-pane-inner" id="general"> | ||||||
|                 <fieldset> |                 <fieldset> | ||||||
|                     <div class="pure-control-group"> |                     <div class="pure-control-group"> | ||||||
|                         {{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }} |                         {{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }} | ||||||
|                         <span class="pure-form-message-inline">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></span> |                         <span class="pure-form-message-inline">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></span><br/> | ||||||
|  |                         <span class="pure-form-message-inline">You can use variables in the URL, perfect for inserting the current date and other logic, <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a></span><br/> | ||||||
|                     </div> |                     </div> | ||||||
|                     <div class="pure-control-group"> |                     <div class="pure-control-group"> | ||||||
|                         {{ render_field(form.title, class="m-d") }} |                         {{ render_field(form.title, class="m-d") }} | ||||||
| @@ -61,6 +72,12 @@ | |||||||
|                     <div class="pure-control-group"> |                     <div class="pure-control-group"> | ||||||
|                         {{ render_checkbox_field(form.extract_title_as_title) }} |                         {{ render_checkbox_field(form.extract_title_as_title) }} | ||||||
|                     </div> |                     </div> | ||||||
|  |                     <div class="pure-control-group"> | ||||||
|  |                         {{ render_checkbox_field(form.filter_failure_notification_send) }} | ||||||
|  |                         <span class="pure-form-message-inline"> | ||||||
|  |                          Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore. | ||||||
|  |                         </span> | ||||||
|  |                     </div> | ||||||
|                 </fieldset> |                 </fieldset> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
| @@ -70,6 +87,7 @@ | |||||||
|                         <span class="pure-form-message-inline"> |                         <span class="pure-form-message-inline"> | ||||||
|                             <p>Use the <strong>Basic</strong> method (default) where your watched site doesn't need Javascript to render.</p> |                             <p>Use the <strong>Basic</strong> method (default) where your watched site doesn't need Javascript to render.</p> | ||||||
|                             <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> |                             <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> | ||||||
|  |                             Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a> | ||||||
|                         </span> |                         </span> | ||||||
|                     </div> |                     </div> | ||||||
|                 {% if form.proxy %} |                 {% if form.proxy %} | ||||||
| @@ -80,34 +98,43 @@ | |||||||
|                         </span> |                         </span> | ||||||
|                     </div> |                     </div> | ||||||
|                 {% endif %} |                 {% endif %} | ||||||
|                 <fieldset id="webdriver-override-options"> |                     <div  class="pure-control-group inline-radio"> | ||||||
|                     <div class="pure-form-message-inline"> |                         {{ render_checkbox_field(form.ignore_status_codes) }} | ||||||
|                         <strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong> |  | ||||||
|                         <br/> |  | ||||||
|                         This will wait <i>n</i> seconds before extracting the text. |  | ||||||
|                     </div> |                     </div> | ||||||
|  |                 <fieldset id="webdriver-override-options"> | ||||||
|                     <div class="pure-control-group"> |                     <div class="pure-control-group"> | ||||||
|                         {{ render_field(form.webdriver_delay) }} |                         {{ render_field(form.webdriver_delay) }} | ||||||
|  |                         <div class="pure-form-message-inline"> | ||||||
|  |                             <strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong> | ||||||
|  |                             <br/> | ||||||
|  |                             This will wait <i>n</i> seconds before extracting the text. | ||||||
|  |                             {% if using_global_webdriver_wait %} | ||||||
|  |                             <br/><strong>Using the current global default settings</strong> | ||||||
|  |                             {% endif %} | ||||||
|  |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|                     {% if using_global_webdriver_wait %} |                     <div class="pure-control-group"> | ||||||
|                     <div class="pure-form-message-inline"> |                         {{ render_field(form.webdriver_js_execute_code) }} | ||||||
|                         <strong>Using the current global default settings</strong> |                         <div class="pure-form-message-inline"> | ||||||
|  |                             Run this code before performing change detection, handy for filling in fields and other actions <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Run-JavaScript-before-change-detection">More help and examples here</a> | ||||||
|  |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|                     {% endif %} |  | ||||||
|                 </fieldset> |                 </fieldset> | ||||||
|                 <fieldset class="pure-group" id="requests-override-options"> |                 <fieldset class="pure-group" id="requests-override-options"> | ||||||
|                     <div class="pure-form-message-inline"> |                     {% if not playwright_enabled %} | ||||||
|                         <strong>Request override is currently only used by the <i>Basic fast Plaintext/HTTP Client</i> method.</strong> |                         <div class="pure-form-message-inline"> | ||||||
|                     </div> |                             <strong>Request override is currently only used by the <i>Basic fast Plaintext/HTTP Client</i> method.</strong> | ||||||
|                     <div class="pure-control-group"> |                         </div> | ||||||
|  |                     {% endif %} | ||||||
|  |                     <div class="pure-control-group" id="request-method"> | ||||||
|                         {{ render_field(form.method) }} |                         {{ render_field(form.method) }} | ||||||
|                     </div> |                     </div> | ||||||
|                     <div class="pure-control-group"> |                     <div class="pure-control-group" id="request-headers"> | ||||||
| {{ render_field(form.headers, rows=5, placeholder="Example | {{ render_field(form.headers, rows=5, placeholder="Example | ||||||
| Cookie: foobar | Cookie: foobar | ||||||
| User-Agent: wonderbra 1.0") }} | User-Agent: wonderbra 1.0") }} | ||||||
|                     </div> |                     </div> | ||||||
|                     <div class="pure-control-group"> |                     <div class="pure-control-group" id="request-body"> | ||||||
|                                         {{ render_field(form.body, rows=5, placeholder="Example |                                         {{ render_field(form.body, rows=5, placeholder="Example | ||||||
| { | { | ||||||
|    \"name\":\"John\", |    \"name\":\"John\", | ||||||
| @@ -115,18 +142,74 @@ User-Agent: wonderbra 1.0") }} | |||||||
|    \"car\":null |    \"car\":null | ||||||
| }") }} | }") }} | ||||||
|                     </div> |                     </div> | ||||||
|                     <div> |                 </fieldset> | ||||||
|                         {{ render_checkbox_field(form.ignore_status_codes) }} |             </div> | ||||||
|  |             {% if playwright_enabled %} | ||||||
|  |             <div class="tab-pane-inner" id="browser-steps"> | ||||||
|  |                 <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}"> | ||||||
|  |                 <fieldset> | ||||||
|  |                     <div class="pure-control-group"> | ||||||
|  |                         <!-- | ||||||
|  |                         Too hard right now, better to just send the events to the fetcher for now and leave it in the final screenshot | ||||||
|  |                         and/or report an error | ||||||
|  |                         <a id="play-steps" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Play steps  ▶</a> | ||||||
|  |                         --> | ||||||
|  |  | ||||||
|  |                         <!---  Do this later --> | ||||||
|  |                         <div class="checkbox" style="display: none;"> | ||||||
|  |                             <input type=checkbox id="include_text_elements" > <label for="include_text_elements">Turn on text finder</label> | ||||||
|  |                         </div> | ||||||
|  |  | ||||||
|  |                         <div id="loading-status-text" style="display: none;">Please wait, first browser step can take a little time to load..<div class="spinner"></div></div> | ||||||
|  |                         <div class="flex-wrapper" > | ||||||
|  |  | ||||||
|  |                             <div id="browser-steps-ui" class="noselect"  style="width: 100%; background-color: #eee; border-radius: 5px;"> | ||||||
|  |  | ||||||
|  |                                 <div class="noselect"  id="browsersteps-selector-wrapper" style="width: 100%"> | ||||||
|  |                                     <span class="loader" > | ||||||
|  |                                         <span id="browsersteps-click-start"> | ||||||
|  |                                             <h2 >Click here to Start</h2> | ||||||
|  |                                             Please allow 10-15 seconds for the browser to connect. | ||||||
|  |                                         </span> | ||||||
|  |                                         <div class="spinner"  style="display: none;"></div> | ||||||
|  |                                     </span> | ||||||
|  |                                     <img  class="noselect" id="browsersteps-img" src="" style="max-width: 100%; width: 100%;" /> | ||||||
|  |                                     <canvas  class="noselect" id="browsersteps-selector-canvas" style="max-width: 100%; width: 100%;"></canvas> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                             <div id="browser-steps-fieldlist" style="padding-left: 1em;  width: 350px; font-size: 80%;" > | ||||||
|  |                                 <span id="browserless-seconds-remaining">Loading</span> <span style="font-size: 80%;"> (<a target=_new href="https://github.com/dgtlmoon/changedetection.io/pull/478/files#diff-1a79d924d1840c485238e66772391268a89c95b781d69091384cf1ea1ac146c9R4">?</a>) </span> | ||||||
|  |                                 {{ render_field(form.browser_steps) }} | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|                 </fieldset> |                 </fieldset> | ||||||
|                 <br/> |  | ||||||
|             </div> |             </div> | ||||||
|  |             {% endif %} | ||||||
|  |  | ||||||
|             <div class="tab-pane-inner" id="notifications"> |             <div class="tab-pane-inner" id="notifications"> | ||||||
|                 <strong>Note: <i>These settings override the global settings for this watch.</i></strong> |  | ||||||
|                 <fieldset> |                 <fieldset> | ||||||
|                     <div class="field-group"> |                     <div  class="pure-control-group inline-radio"> | ||||||
|                         {{ render_common_settings_form(form, current_base_url, emailprefix) }} |                       {{ render_checkbox_field(form.notification_muted) }} | ||||||
|  |                     </div> | ||||||
|  |                     {% if is_html_webdriver %} | ||||||
|  |                     <div class="pure-control-group inline-radio"> | ||||||
|  |                       {{ render_checkbox_field(form.notification_screenshot) }} | ||||||
|  |                         <span class="pure-form-message-inline"> | ||||||
|  |                             <strong>Use with caution!</strong> This will easily fill up your email storage quota or flood other storages. | ||||||
|  |                         </span> | ||||||
|  |                     </div> | ||||||
|  |                     {% endif %} | ||||||
|  |                     <div class="field-group" id="notification-field-group"> | ||||||
|  |                         {% if has_default_notification_urls %} | ||||||
|  |                         <div class="inline-warning"> | ||||||
|  |                             <img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!"/> | ||||||
|  |                             There are <a href="{{ url_for('settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications. | ||||||
|  |                         </div> | ||||||
|  |                         {% endif %} | ||||||
|  |                         <a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">Use system defaults</a> | ||||||
|  |  | ||||||
|  |                         {{ render_common_settings_form(form, emailprefix, settings_application) }} | ||||||
|                     </div> |                     </div> | ||||||
|                 </fieldset> |                 </fieldset> | ||||||
|             </div> |             </div> | ||||||
| @@ -143,18 +226,45 @@ User-Agent: wonderbra 1.0") }} | |||||||
|                                 </li> |                                 </li> | ||||||
|                             </ul> |                             </ul> | ||||||
|                     </div> |                     </div> | ||||||
|  |                     <fieldset> | ||||||
|  |                         <div class="pure-control-group"> | ||||||
|  |                             {{ render_checkbox_field(form.check_unique_lines) }} | ||||||
|  |                             <span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span> | ||||||
|  |                         </div> | ||||||
|  |                     </fieldset> | ||||||
|                     <div class="pure-control-group"> |                     <div class="pure-control-group"> | ||||||
|                         {{ render_field(form.css_filter, placeholder=".class-name or #some-id, or other CSS selector rule.", |                         {% set field = render_field(form.include_filters, | ||||||
|                         class="m-d") }} |                             rows=5, | ||||||
|                         <span class="pure-form-message-inline"> |                             placeholder="#example | ||||||
|  | xpath://body/div/span[contains(@class, 'example-class')]", | ||||||
|  |                             class="m-d") | ||||||
|  |                         %} | ||||||
|  |                         {{ field }} | ||||||
|  |                         {% if '/text()' in  field %} | ||||||
|  |                           <span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br/> | ||||||
|  |                         {% endif %} | ||||||
|  |                         <span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br/> | ||||||
|                     <ul> |                     <ul> | ||||||
|                         <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li> |                         <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li> | ||||||
|                         <li>JSON - Limit text to this JSON rule, using <a href="https://pypi.org/project/jsonpath-ng/">JSONPath</a>, prefix with <code>"json:"</code>, use <code>json:$</code> to force re-formatting if required,  <a |                         <li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed). | ||||||
|                                 href="https://jsonpath.com/" target="new">test your JSONPath here</a></li> |                             <ul> | ||||||
|                         <li>XPath - Limit text to this XPath rule, simply start with a forward-slash, example  <code>//*[contains(@class, 'sametext')]</code> or <code>xpath://*[contains(@class, 'sametext')]</code>, <a |                                 <li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required,  <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li> | ||||||
|  |                                 {% if jq_support %} | ||||||
|  |                                 <li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>.</li> | ||||||
|  |                                 {% else %} | ||||||
|  |                                 <li>jq support not installed</li> | ||||||
|  |                                 {% endif %} | ||||||
|  |                             </ul> | ||||||
|  |                         </li> | ||||||
|  |                         <li>XPath - Limit text to this XPath rule, simply start with a forward-slash, | ||||||
|  |                             <ul> | ||||||
|  |                                 <li>Example:  <code>//*[contains(@class, 'sametext')]</code> or <code>xpath://*[contains(@class, 'sametext')]</code>, <a | ||||||
|                                 href="http://xpather.com/" target="new">test your XPath here</a></li> |                                 href="http://xpather.com/" target="new">test your XPath here</a></li> | ||||||
|  |                                 <li>Example: Get all titles from an RSS feed <code>//title/text()</code></li> | ||||||
|  |                             </ul> | ||||||
|  |                             </li> | ||||||
|                     </ul> |                     </ul> | ||||||
|                     Please be sure that you thoroughly understand how to write CSS or JSONPath, XPath selector rules before filing an issue on GitHub! <a |                     Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a | ||||||
|                                 href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br/> |                                 href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br/> | ||||||
|                 </span> |                 </span> | ||||||
|                     </div> |                     </div> | ||||||
| @@ -177,7 +287,7 @@ nav | |||||||
|                     <span class="pure-form-message-inline"> |                     <span class="pure-form-message-inline"> | ||||||
|                         <ul> |                         <ul> | ||||||
|                             <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> |                             <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> | ||||||
|                             <li>Regular Expression support, wrap the line in forward slash <code>/regex/</code></li> |                             <li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li> | ||||||
|                             <li>Changing this will affect the comparison checksum which may trigger an alert</li> |                             <li>Changing this will affect the comparison checksum which may trigger an alert</li> | ||||||
|                             <li>Use the preview/show current tab to see ignores</li> |                             <li>Use the preview/show current tab to see ignores</li> | ||||||
|                         </ul> |                         </ul> | ||||||
| @@ -199,42 +309,69 @@ nav | |||||||
|                         </span> |                         </span> | ||||||
|                     </div> |                     </div> | ||||||
|                 </fieldset> |                 </fieldset> | ||||||
|  |                 <fieldset> | ||||||
|  |                     <div class="pure-control-group"> | ||||||
|  |                         {{ render_field(form.text_should_not_be_present, rows=5, placeholder="For example: Out of stock | ||||||
|  | Sold out | ||||||
|  | Not in stock | ||||||
|  | Unavailable") }} | ||||||
|  |                         <span class="pure-form-message-inline"> | ||||||
|  |                             <ul> | ||||||
|  |                                 <li>Block change-detection while this text is on the page, all text and regex are tested <i>case-insensitive</i>, good for waiting for when a product is available again</li> | ||||||
|  |                                 <li>Block text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li> | ||||||
|  |                                 <li>All lines here must not exist (think of each line as "OR")</li> | ||||||
|  |                                 <li>Note: Wrap in forward slash / to use regex  example: <code>/foo\d/</code></li> | ||||||
|  |                             </ul> | ||||||
|  |                         </span> | ||||||
|  |                     </div> | ||||||
|  |                 </fieldset> | ||||||
|  |                 <fieldset> | ||||||
|  |                     <div class="pure-control-group"> | ||||||
|  |                         {{ render_field(form.extract_text, rows=5, placeholder="\d+ online") }} | ||||||
|  |                         <span class="pure-form-message-inline"> | ||||||
|  |                     <ul> | ||||||
|  |                         <li>Extracts text in the final output (line by line) after other filters using regular expressions; | ||||||
|  |                             <ul> | ||||||
|  |                                 <li>Regular expression ‐ example <code>/reports.+?2022/i</code></li> | ||||||
|  |                                 <li>Use <code>//(?aiLmsux))</code> type flags (more <a href="https://docs.python.org/3/library/re.html#index-15">information here</a>)<br/></li> | ||||||
|  |                                 <li>Keyword example ‐ example <code>Out of stock</code></li> | ||||||
|  |                                 <li>Use groups to extract just that text ‐ example <code>/reports.+?(\d+)/i</code> returns a list of years only</li> | ||||||
|  |                             </ul> | ||||||
|  |                         </li> | ||||||
|  |                         <li>One line per regular-expression/ string match</li> | ||||||
|  |                     </ul> | ||||||
|  |                         </span> | ||||||
|  |                     </div> | ||||||
|  |                 </fieldset> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <div class="tab-pane-inner visual-selector-ui" id="visualselector"> |             <div class="tab-pane-inner visual-selector-ui" id="visualselector"> | ||||||
|                 <img id="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}"> |                 <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}"> | ||||||
|  |  | ||||||
|                 <fieldset> |                 <fieldset> | ||||||
|                     <div class="pure-control-group"> |                     <div class="pure-control-group"> | ||||||
|                         {% if visualselector_enabled %} |                         {% if visualselector_enabled %} | ||||||
|                             {% if visualselector_data_is_ready %} |  | ||||||
|                                 <div id="selector-header"> |  | ||||||
|                                     <a id="clear-selector" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Clear selection</a> |  | ||||||
|                                     <i class="fetching-update-notice" style="font-size: 80%;">One moment, fetching screenshot and element information..</i> |  | ||||||
|                                 </div> |  | ||||||
|                                 <div id="selector-wrapper"> |  | ||||||
|                                     <!-- request the screenshot and get the element offset info ready --> |  | ||||||
|                                     <!-- use img src ready load to know everything is ready to map out --> |  | ||||||
|                                     <!-- @todo: maybe something interesting like a field to select 'elements that contain text... and their parents n' --> |  | ||||||
|                                     <img id="selector-background" /> |  | ||||||
|                                     <canvas id="selector-canvas"></canvas> |  | ||||||
|  |  | ||||||
|                                 </div> |  | ||||||
|                                 <div id="selector-current-xpath" style="overflow-x: hidden"><strong>Currently:</strong> <span class="text">Loading...</span></div> |  | ||||||
|  |  | ||||||
|                             <span class="pure-form-message-inline"> |                             <span class="pure-form-message-inline"> | ||||||
|                                 <p><span style="font-weight: bold">Beta!</span> The Visual Selector is new and there may be minor bugs, please report pages that dont work, help us to improve this software!</p> |                                 The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection ‐ after the <i>Browser Steps</i> has completed.<br/><br/> | ||||||
|                             </span> |                             </span> | ||||||
|  |  | ||||||
|                             {% else %} |                             <div id="selector-header"> | ||||||
|                                 <span class="pure-form-message-inline">Screenshot and element data is not available or not yet ready.</span> |                                 <a id="clear-selector" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Clear selection</a> | ||||||
|                             {% endif %} |                                 <i class="fetching-update-notice" style="font-size: 80%;">One moment, fetching screenshot and element information..</i> | ||||||
|  |                             </div> | ||||||
|  |                             <div id="selector-wrapper" style="display: none"> | ||||||
|  |                                 <!-- request the screenshot and get the element offset info ready --> | ||||||
|  |                                 <!-- use img src ready load to know everything is ready to map out --> | ||||||
|  |                                 <!-- @todo: maybe something interesting like a field to select 'elements that contain text... and their parents n' --> | ||||||
|  |                                 <img id="selector-background" /> | ||||||
|  |                                 <canvas id="selector-canvas"></canvas> | ||||||
|  |                             </div> | ||||||
|  |                             <div id="selector-current-xpath" style="overflow-x: hidden"><strong>Currently:</strong> <span class="text">Loading...</span></div> | ||||||
|                         {% else %} |                         {% else %} | ||||||
|                             <span class="pure-form-message-inline"> |                             <span class="pure-form-message-inline"> | ||||||
|                                 <p>Sorry, this functionality only works with Playwright/Chrome enabled watches.</p> |                                 <p>Sorry, this functionality only works with Playwright/Chrome enabled watches.</p> | ||||||
|                                 <p>Enable the Playwright Chrome fetcher, or alternatively try our <a href="https://lemonade.changedetection.io/start">very affordable subscription based service</a>.</p> |                                 <p>Enable the Playwright Chrome fetcher, or alternatively try our <a href="https://lemonade.changedetection.io/start">very affordable subscription based service</a>.</p> | ||||||
|                                 <p>This is because Selenium/WebDriver can not extract full page screenshots reliably.</p> |                                 <p>This is because Selenium/WebDriver can not extract full page screenshots reliably.</p> | ||||||
|  |  | ||||||
|                             </span> |                             </span> | ||||||
|                         {% endif %} |                         {% endif %} | ||||||
|                     </div> |                     </div> | ||||||
| @@ -243,11 +380,11 @@ nav | |||||||
|  |  | ||||||
|             <div id="actions"> |             <div id="actions"> | ||||||
|                 <div class="pure-control-group"> |                 <div class="pure-control-group"> | ||||||
|  |                     {{ render_button(form.save_button) }} | ||||||
|                       {{ render_button(form.save_button) }} {{ render_button(form.save_and_preview_button) }} |  | ||||||
|  |  | ||||||
|                     <a href="{{url_for('form_delete', uuid=uuid)}}" |                     <a href="{{url_for('form_delete', uuid=uuid)}}" | ||||||
|                        class="pure-button button-small button-error ">Delete</a> |                        class="pure-button button-small button-error ">Delete</a> | ||||||
|  |                     <a href="{{url_for('clear_watch_history', uuid=uuid)}}" | ||||||
|  |                        class="pure-button button-small button-error ">Clear History</a> | ||||||
|                     <a href="{{url_for('form_clone', uuid=uuid)}}" |                     <a href="{{url_for('form_clone', uuid=uuid)}}" | ||||||
|                        class="pure-button button-small ">Create Copy</a> |                        class="pure-button button-small ">Create Copy</a> | ||||||
|                 </div> |                 </div> | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ | |||||||
|  |  | ||||||
|     <div class="tabs collapsable"> |     <div class="tabs collapsable"> | ||||||
|         <ul> |         <ul> | ||||||
|             <li class="tab" id="default-tab"><a href="#url-list">URL List</a></li> |             <li class="tab" id=""><a href="#url-list">URL List</a></li> | ||||||
|             <li class="tab"><a href="#distill-io">Distill.io</a></li> |             <li class="tab"><a href="#distill-io">Distill.io</a></li> | ||||||
|         </ul> |         </ul> | ||||||
|     </div> |     </div> | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ | |||||||
| <div class="edit-form"> | <div class="edit-form"> | ||||||
|      <div class="inner"> |      <div class="inner"> | ||||||
|  |  | ||||||
|          <h4 style="margin-top: 0px;">The following issues were detected when sending notifications</h4> |          <h4 style="margin-top: 0px;">Notification debug log</h4> | ||||||
|                 <div id="notification-error-log"> |                 <div id="notification-error-log"> | ||||||
|                 <ul style="font-size: 80%; margin:0px; padding: 0 0 0 7px"> |                 <ul style="font-size: 80%; margin:0px; padding: 0 0 0 7px"> | ||||||
|                 {% for log in logs|reverse %} |                 {% for log in logs|reverse %} | ||||||
|   | |||||||
| @@ -1,20 +1,41 @@ | |||||||
| {% extends 'base.html' %} | {% extends 'base.html' %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  | <script> | ||||||
| <div id="settings"> |     const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}"; | ||||||
|     <h1>Current - {{watch.last_checked|format_timestamp_timeago}}</h1> |     {% if last_error_screenshot %} | ||||||
| </div> |     const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||||
|  |     {% endif %} | ||||||
|  | </script> | ||||||
|  | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> | ||||||
|  |  | ||||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||||
| <div class="tabs"> | <div class="tabs"> | ||||||
|     <ul> |     <ul> | ||||||
|         <li class="tab" id="default-tab"><a href="#text">Text</a></li> |         {% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %} | ||||||
|  |         {% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">Error Screenshot</a></li> {% endif %} | ||||||
|  |         {% if history_n > 0 %} | ||||||
|  |         <li class="tab" id="text-tab"><a href="#text">Text</a></li> | ||||||
|  |         <li class="tab" id="screenshot-tab"><a href="#screenshot">Screenshot</a></li> | ||||||
|  |         {% endif %} | ||||||
|     </ul> |     </ul> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <div id="diff-ui"> | <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="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="ignored">Grey lines are ignored</span> <span class="triggered">Blue lines are triggers</span> | ||||||
|         <table> |         <table> | ||||||
|             <tbody> |             <tbody> | ||||||
| @@ -28,5 +49,22 @@ | |||||||
|             </tbody> |             </tbody> | ||||||
|         </table> |         </table> | ||||||
|     </div> |     </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> | ||||||
| {% endblock %} | {% endblock %} | ||||||
| @@ -1,32 +0,0 @@ | |||||||
| {% extends 'base.html' %} |  | ||||||
|  |  | ||||||
| {% block content %} |  | ||||||
| <div class="edit-form"> |  | ||||||
|     <div class="box-wrap inner"> |  | ||||||
|     <form class="pure-form pure-form-stacked" action="{{url_for('scrub_page')}}" method="POST"> |  | ||||||
|         <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> |  | ||||||
|         <fieldset> |  | ||||||
|             <div class="pure-control-group"> |  | ||||||
|                 This will remove ALL version snapshots/data, but keep your list of URLs. <br/> |  | ||||||
|                 You may like to use the <strong>BACKUP</strong> link first.<br/> |  | ||||||
|             </div> |  | ||||||
|             <br/> |  | ||||||
|             <div class="pure-control-group"> |  | ||||||
|                 <label for="confirmtext">Confirmation text</label> |  | ||||||
|                 <input type="text" id="confirmtext" required="" name="confirmtext" value="" size="10"/> |  | ||||||
|                 <span class="pure-form-message-inline">Type in the word <strong>scrub</strong> to confirm that you understand!</span> |  | ||||||
|             </div> |  | ||||||
|             <br/> |  | ||||||
|             <div class="pure-control-group"> |  | ||||||
|                 <button type="submit" class="pure-button pure-button-primary">Scrub!</button> |  | ||||||
|             </div> |  | ||||||
|             <br/> |  | ||||||
|             <div class="pure-control-group"> |  | ||||||
|                 <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Cancel</a> |  | ||||||
|             </div> |  | ||||||
|         </fieldset> |  | ||||||
|     </form> |  | ||||||
|     </div> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| {% endblock %} |  | ||||||
| @@ -16,7 +16,7 @@ | |||||||
| <div class="edit-form"> | <div class="edit-form"> | ||||||
|     <div class="tabs collapsable"> |     <div class="tabs collapsable"> | ||||||
|         <ul> |         <ul> | ||||||
|             <li class="tab" id="default-tab"><a href="#general">General</a></li> |             <li class="tab" id=""><a href="#general">General</a></li> | ||||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> |             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||||
|             <li class="tab"><a href="#fetching">Fetching</a></li> |             <li class="tab"><a href="#fetching">Fetching</a></li> | ||||||
|             <li class="tab"><a href="#filters">Global Filters</a></li> |             <li class="tab"><a href="#filters">Global Filters</a></li> | ||||||
| @@ -32,6 +32,17 @@ | |||||||
|                         {{ render_field(form.requests.form.time_between_check, class="time-check-widget") }} |                         {{ 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 time for all watches, when the watch does not have a specific time setting.</span> | ||||||
|                     </div> |                     </div> | ||||||
|  |                     <div class="pure-control-group"> | ||||||
|  |                         {{ render_field(form.requests.form.jitter_seconds, class="jitter_seconds") }} | ||||||
|  |                         <span class="pure-form-message-inline">Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later</span> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="pure-control-group"> | ||||||
|  |                         {{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class="filter_failure_notification_threshold_attempts") }} | ||||||
|  |                         <span class="pure-form-message-inline">After this many consecutive times that the CSS/xPath filter is missing, send a notification | ||||||
|  |                             <br/> | ||||||
|  |                         Set to <strong>0</strong> to disable | ||||||
|  |                         </span> | ||||||
|  |                     </div> | ||||||
|                     <div class="pure-control-group"> |                     <div class="pure-control-group"> | ||||||
|                         {% if not hide_remove_pass %} |                         {% if not hide_remove_pass %} | ||||||
|                             {% if current_user.is_authenticated %} |                             {% if current_user.is_authenticated %} | ||||||
| @@ -49,7 +60,7 @@ | |||||||
|                         {{ render_field(form.application.form.base_url, placeholder="http://yoursite.com:5000/", |                         {{ render_field(form.application.form.base_url, placeholder="http://yoursite.com:5000/", | ||||||
|                         class="m-d") }} |                         class="m-d") }} | ||||||
|                         <span class="pure-form-message-inline"> |                         <span class="pure-form-message-inline"> | ||||||
|                             Base URL used for the {base_url} token in notifications and RSS links.<br/>Default value is the ENV var 'BASE_URL' (Currently "{{current_base_url}}"), |                             Base URL used for the <code>{base_url}</code> token in notifications and RSS links.<br/>Default value is the ENV var 'BASE_URL' (Currently "{{settings_application['current_base_url']}}"), | ||||||
|                             <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>. |                             <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>. | ||||||
|                         </span> |                         </span> | ||||||
|                     </div> |                     </div> | ||||||
| @@ -58,12 +69,6 @@ | |||||||
|                         {{ render_checkbox_field(form.application.form.extract_title_as_title) }} |                         {{ render_checkbox_field(form.application.form.extract_title_as_title) }} | ||||||
|                         <span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span> |                         <span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span> | ||||||
|                     </div> |                     </div> | ||||||
|  |  | ||||||
|                     <div class="pure-control-group"> |  | ||||||
|                         {{ render_checkbox_field(form.application.form.real_browser_save_screenshot) }} |  | ||||||
|                         <span class="pure-form-message-inline">When using a Chrome browser, a screenshot from the last check will be available on the Diff page</span> |  | ||||||
|                     </div> |  | ||||||
|  |  | ||||||
|                     <div class="pure-control-group"> |                     <div class="pure-control-group"> | ||||||
|                         {{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }} |                         {{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }} | ||||||
|                         <span class="pure-form-message-inline">When a page contains HTML, but no renderable text appears (empty page), is this considered a change?</span> |                         <span class="pure-form-message-inline">When a page contains HTML, but no renderable text appears (empty page), is this considered a change?</span> | ||||||
| @@ -82,7 +87,7 @@ | |||||||
|             <div class="tab-pane-inner" id="notifications"> |             <div class="tab-pane-inner" id="notifications"> | ||||||
|                 <fieldset> |                 <fieldset> | ||||||
|                     <div class="field-group"> |                     <div class="field-group"> | ||||||
|                         {{ render_common_settings_form(form.application.form, current_base_url, emailprefix) }} |                         {{ render_common_settings_form(form.application.form, emailprefix, settings_application) }} | ||||||
|                     </div> |                     </div> | ||||||
|                 </fieldset> |                 </fieldset> | ||||||
|             </div> |             </div> | ||||||
| @@ -94,6 +99,8 @@ | |||||||
|                         <p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p> |                         <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> |                         <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> |                     </span> | ||||||
|  |                     <br/> | ||||||
|  |                     Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a> | ||||||
|                 </div> |                 </div> | ||||||
|                 <fieldset class="pure-group" id="webdriver-override-options"> |                 <fieldset class="pure-group" id="webdriver-override-options"> | ||||||
|                     <div class="pure-form-message-inline"> |                     <div class="pure-form-message-inline"> | ||||||
| @@ -143,7 +150,7 @@ nav | |||||||
|                         <ul> |                         <ul> | ||||||
|                             <li>Note: This is applied globally in addition to the per-watch rules.</li> |                             <li>Note: This is applied globally in addition to the per-watch rules.</li> | ||||||
|                             <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> |                             <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> | ||||||
|                             <li>Regular Expression support, wrap the line in forward slash <code>/regex/</code></li> |                             <li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li> | ||||||
|                             <li>Changing this will affect the comparison checksum which may trigger an alert</li> |                             <li>Changing this will affect the comparison checksum which may trigger an alert</li> | ||||||
|                             <li>Use the preview/show current tab to see ignores</li> |                             <li>Use the preview/show current tab to see ignores</li> | ||||||
|                         </ul> |                         </ul> | ||||||
| @@ -168,7 +175,7 @@ nav | |||||||
|                 <div class="pure-control-group"> |                 <div class="pure-control-group"> | ||||||
|                     {{ render_button(form.save_button) }} |                     {{ render_button(form.save_button) }} | ||||||
|                     <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Back</a> |                     <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Back</a> | ||||||
|                     <a href="{{url_for('scrub_page')}}" class="pure-button button-small button-cancel">Delete History Snapshot Data</a> |                     <a href="{{url_for('clear_all_history')}}" class="pure-button button-small button-cancel">Clear Snapshot History</a> | ||||||
|                 </div> |                 </div> | ||||||
|  |  | ||||||
|             </div> |             </div> | ||||||
|   | |||||||
| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 262.86"><path fill-rule="nonzero" d="M316.78 16.55h-205.9c-30.5 0-58.22 12.48-78.31 32.57C12.47 69.21 0 96.93 0 127.44c0 30.5 12.47 58.22 32.57 78.31 20.09 20.1 47.81 32.57 78.31 32.57h193.25c21.54 15.43 47.9 24.54 76.26 24.54h.18c36.14 0 69.02-14.79 92.83-38.6 23.8-23.81 38.6-56.67 38.6-92.83 0-36.15-14.78-69.03-38.63-92.8C449.53 14.8 416.67 0 380.57 0h-.18c-23.02 0-44.72 6.02-63.61 16.55zm70.62 97.17.43.09c.82-3.45 2.83-6.19 6.04-8.16 3.2-1.98 6.53-2.57 10.01-1.75l.1-.43c-3.47-.82-6.2-2.83-8.17-6.03-1.98-3.22-2.57-6.55-1.75-10.01l-.43-.1c-.82 3.47-2.83 6.2-6.03 8.18-3.21 1.98-6.55 2.56-10.02 1.74l-.1.43c3.47.82 6.2 2.84 8.18 6.04 1.99 3.19 2.56 6.52 1.74 10zm36.87 16.77.53.12c1.02-4.35 3.55-7.78 7.58-10.26 4.02-2.49 8.2-3.22 12.56-2.19l.13-.53c-4.35-1.03-7.78-3.55-10.26-7.59-2.49-4.03-3.22-8.22-2.2-12.56l-.53-.12c-1.02 4.35-3.55 7.77-7.58 10.26-4.02 2.49-8.21 3.22-12.56 2.19l-.13.53c4.36 1.03 7.78 3.55 10.26 7.58 2.49 4.02 3.22 8.22 2.2 12.57zm-38.79-61.01c-15.69 7.67-26.98 23.26-28.29 41.93-1.96 27.88 19.05 52.06 46.92 54.02 13.23.93 25.64-3.32 35.22-11.02 4.75-3.82 9.66-.45 7.59 4.36-11.33 26.42-38.45 44.04-68.74 41.91-38.29-2.69-67.14-35.91-64.45-74.19C316.3 89.8 347.05 61.67 383.44 62c6.71.06 8.13 4.5 2.04 7.48zm-5.09-53.95h.18c63.75 0 115.91 52.15 115.91 115.9 0 63.75-52.23 115.91-115.91 115.91h-.18c-63.68 0-115.91-52.16-115.91-115.91s52.16-115.9 115.91-115.9z"/></svg> | ||||||
| After Width: | Height: | Size: 1.6 KiB | 
							
								
								
									
										3
									
								
								changedetectionio/templates/svgs/github.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | |||||||
|  | <svg class="octicon octicon-mark-github v-align-middle" height="32" viewbox="0 0 16 16" version="1.1" width="32" aria-hidden="true"> | ||||||
|  |   <path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 749 B | 
| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 256.04"><path d="M128.02 0h.18c22.03 0 42.83 5.66 61 15.6h210.38c30.89 0 59 12.65 79.38 33.04C499.35 68.99 512 97.1 512 128.02c0 30.92-12.66 59.03-33.02 79.4l-.42.38c-20.34 20.15-48.29 32.64-78.98 32.64H189.24c-18.17 9.93-38.98 15.6-61.04 15.6h-.18c-35.2 0-67.22-14.41-90.42-37.6C14.41 195.25 0 163.24 0 128.02s14.4-67.24 37.59-90.43l.91-.83C61.65 14.05 93.29 0 128.02 0zm-5.95 54.42c0-1.95.8-3.73 2.08-5 2.74-2.77 7.27-2.76 10.02-.01l.14.16a7.042 7.042 0 0 1 1.94 4.85v12.95c0 1.95-.8 3.73-2.08 5.01-2.75 2.75-7.27 2.75-10.02 0a7.084 7.084 0 0 1-2.08-5.01V54.42zm6.05 31.17c11.72 0 22.32 4.75 30 12.43 7.67 7.68 12.43 18.29 12.43 30 0 11.72-4.75 22.32-12.43 30s-18.28 12.43-30 12.43c-11.72 0-22.32-4.75-30.01-12.43-7.67-7.68-12.43-18.28-12.43-30 0-11.72 4.76-22.32 12.43-30 7.69-7.67 18.3-12.43 30.01-12.43zm-56.33-5.34a7.114 7.114 0 0 1-2.07-5.01c0-3.9 3.18-7.09 7.09-7.09 1.81 0 3.62.69 5 2.07l9.16 9.16a7.065 7.065 0 0 1 2.08 5.01c0 1.8-.7 3.62-2.08 5.01a7.057 7.057 0 0 1-5.01 2.08c-1.8 0-3.61-.7-5-2.07l-9.17-9.16zm-17.28 53.81c-1.95 0-3.73-.8-5-2.08-2.77-2.74-2.76-7.27-.01-10.01l.15-.14a7.04 7.04 0 0 1 4.86-1.94h12.94a7.082 7.082 0 0 1 7.09 7.09c0 1.95-.8 3.73-2.07 5.01a7.099 7.099 0 0 1-5.02 2.07H54.51zm25.82 50.28a7.049 7.049 0 0 1-5 2.07c-3.91 0-7.09-3.16-7.09-7.08 0-1.81.68-3.62 2.07-5.01l9.31-9.29a7.02 7.02 0 0 1 4.86-1.94 7.09 7.09 0 0 1 7.09 7.09c0 1.79-.69 3.6-2.08 4.99l-9.16 9.17zm53.82 17.29c0 1.94-.8 3.73-2.08 5-2.74 2.76-7.27 2.75-10.02 0l-.13-.15a7.033 7.033 0 0 1-1.94-4.85v-12.95c0-1.96.8-3.73 2.07-5.01 2.76-2.75 7.27-2.75 10.03 0a7.1 7.1 0 0 1 2.07 5.01v12.95zm50.28-25.83a7.055 7.055 0 0 1 2.07 5.01c0 3.89-3.18 7.09-7.08 7.09-1.81 0-3.63-.69-5.01-2.07l-9.16-9.16a7.095 7.095 0 0 1-2.07-5.02c0-3.9 3.18-7.09 7.08-7.09 1.8 0 3.61.7 5 2.08l9.17 9.16zm17.29-53.82c1.93 0 3.73.81 5 2.08 2.76 2.75 2.75 7.27 0 10.02l-.15.14a7.098 7.098 0 0 1-4.85 1.94h-12.95c-1.96 0-3.74-.8-5.01-2.08-2.76-2.75-2.76-7.27 0-10.02a7.049 7.049 0 0 1 5.01-2.08h12.95zM175.89 71.7a7.074 7.074 0 0 1 5-2.07c3.9 0 7.1 3.19 7.1 7.09 0 1.81-.69 3.62-2.07 5l-9.32 9.31a7.12 7.12 0 0 1-4.86 1.93c-3.91 0-7.09-3.18-7.09-7.09 0-1.8.7-3.61 2.08-5l9.16-9.17zm34.17-41.87c2.96 2.47 5.81 5.07 8.53 7.8 23.22 23.15 37.63 55.17 37.63 90.39s-14.42 67.23-37.6 90.42a130.2 130.2 0 0 1-8.5 7.77h189.46c26.83 0 51.24-10.91 69.02-28.5l.32-.35c17.79-17.79 28.85-42.35 28.85-69.34 0-26.99-11.06-51.55-28.85-69.35-17.77-17.8-42.33-28.84-69.34-28.84H210.06zm-82.04-14.71h.18c62.09 0 112.89 50.81 112.89 112.9 0 62.1-50.86 112.9-112.89 112.9h-.18c-62.03 0-112.9-50.8-112.9-112.9 0-62.09 50.81-112.9 112.9-112.9z"/></svg> | ||||||
| After Width: | Height: | Size: 2.7 KiB | 
| @@ -1,21 +1,40 @@ | |||||||
| {% extends 'base.html' %} | {% extends 'base.html' %} | ||||||
| {% block content %} | {% block content %} | ||||||
| {% from '_helpers.jinja' import render_simple_field %} | {% from '_helpers.jinja' import render_simple_field, render_field %} | ||||||
|  | {% from '_pagination.jinja' import pagination %} | ||||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> | ||||||
| <script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script> | <script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script> | ||||||
|  |  | ||||||
| <div class="box"> | <div class="box"> | ||||||
|  |  | ||||||
|     <form class="pure-form" action="{{ url_for('form_watch_add') }}" method="POST" id="new-watch-form"> |     <form class="pure-form" action="{{ url_for('form_quick_watch_add') }}" method="POST" id="new-watch-form"> | ||||||
|         <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> |         <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||||
|         <fieldset> |         <fieldset> | ||||||
|             <legend>Add a new change detection watch</legend> |             <legend>Add a new change detection watch</legend> | ||||||
|                 {{ render_simple_field(form.url, placeholder="https://...", required=true) }} |             <div id="watch-add-wrapper-zone"> | ||||||
|                 {{ render_simple_field(form.tag, value=active_tag if active_tag else '', placeholder="watch group") }} |                 <div> | ||||||
|             <button type="submit" class="pure-button pure-button-primary">Watch</button> |                     {{ render_simple_field(form.url, placeholder="https://...", required=true) }} | ||||||
|  |                     {{ render_simple_field(form.tag, value=active_tag if active_tag else '', placeholder="watch group") }} | ||||||
|  |                 </div> | ||||||
|  |                 <div> | ||||||
|  |                     {{ render_simple_field(form.watch_submit_button, title="Watch this URL!" ) }} | ||||||
|  |                     {{ render_simple_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }} | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|         </fieldset> |         </fieldset> | ||||||
|         <span style="color:#eee; font-size: 80%;"><img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread.svg')}}" /> Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></a></span> |         <span style="color:#eee; font-size: 80%;"><img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" /> Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></a></span> | ||||||
|     </form> |     </form> | ||||||
|  |  | ||||||
|  |     <form class="pure-form" action="{{ url_for('form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form"> | ||||||
|  |     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||||
|  |     <div id="checkbox-operations"> | ||||||
|  |         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="pause">Pause</button> | ||||||
|  |         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="unpause">UnPause</button> | ||||||
|  |         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="mute">Mute</button> | ||||||
|  |         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="unmute">UnMute</button> | ||||||
|  |         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="notification-default">Use default notification</button> | ||||||
|  |         <button class="pure-button button-secondary button-xsmall" style="background: #dd4242; font-size: 70%" name="op" value="delete">Delete</button> | ||||||
|  |     </div> | ||||||
|     <div> |     <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 }}">All</a> | ||||||
|         {% for tag in tags %} |         {% for tag in tags %} | ||||||
| @@ -25,22 +44,32 @@ | |||||||
|         {% endfor %} |         {% endfor %} | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|  |     {% set sort_order = request.args.get('order', 'asc') == 'asc' %} | ||||||
|  |     {% set sort_attribute = request.args.get('sort', 'last_changed')   %} | ||||||
|  |     {% set pagination_page = request.args.get('page', 0) %} | ||||||
|  |  | ||||||
|     <div id="watch-table-wrapper"> |     <div id="watch-table-wrapper"> | ||||||
|         <table class="pure-table pure-table-striped watch-table"> |         <table class="pure-table pure-table-striped watch-table"> | ||||||
|             <thead> |             <thead> | ||||||
|             <tr> |             <tr> | ||||||
|                 <th>#</th> |                 <th><input style="vertical-align: middle" type="checkbox" id="check-all"/> #</th> | ||||||
|                 <th></th> |                 <th></th> | ||||||
|                 <th></th> |                 {% set link_order = "desc" if sort_order else "asc" %} | ||||||
|                 <th>Last Checked</th> |                 {% set arrow_span = "" %} | ||||||
|                 <th>Last Changed</th> |                 <th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('index', sort='label', order=link_order)}}">Website <span class='arrow {{link_order}}'></span></a></th> | ||||||
|  |                 <th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order)}}">Last Checked <span class='arrow {{link_order}}'></span></a></th> | ||||||
|  |                 <th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th> | ||||||
|                 <th></th> |                 <th></th> | ||||||
|             </tr> |             </tr> | ||||||
|             </thead> |             </thead> | ||||||
|             <tbody> |             <tbody> | ||||||
|  |  | ||||||
|  |             {% set sorted_watches = watches|sort(attribute=sort_attribute, reverse=sort_order) %} | ||||||
|  |             {% for watch in sorted_watches %} | ||||||
|  |  | ||||||
|             {% for watch in watches %} |             {# WIP for pagination, disabled for now | ||||||
|  |               {% if not ( loop.index >= 3 and loop.index <=4) %}{% continue %}{% endif %} --> | ||||||
|  |              #} | ||||||
|             <tr id="{{ watch.uuid }}" |             <tr id="{{ watch.uuid }}" | ||||||
|                 class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} |                 class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} | ||||||
|                 {% if watch.last_error is defined and watch.last_error != False %}error{% endif %} |                 {% if watch.last_error is defined and watch.last_error != False %}error{% endif %} | ||||||
| @@ -48,12 +77,18 @@ | |||||||
|                 {% if watch.paused is defined and watch.paused != False %}paused{% endif %} |                 {% if watch.paused is defined and watch.paused != False %}paused{% endif %} | ||||||
|                 {% if watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}unviewed{% endif %} |                 {% if watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}unviewed{% endif %} | ||||||
|                 {% if watch.uuid in queued_uuids %}queued{% endif %}"> |                 {% if watch.uuid in queued_uuids %}queued{% endif %}"> | ||||||
|                 <td class="inline">{{ loop.index }}</td> |                 <td class="inline checkbox-uuid" ><input name="uuids"  type="checkbox" value="{{ watch.uuid}} "/> <span>{{ loop.index }}</span></td> | ||||||
|                 <td class="inline paused-state state-{{watch.paused}}"><a href="{{url_for('index', pause=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause" title="Pause"/></a></td> |                 <td class="inline 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> | ||||||
|  |                     {% 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> | ||||||
|  |                     {% 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> | ||||||
|  |                 </td> | ||||||
|                 <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} |                 <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} | ||||||
|                     <a class="external" target="_blank" rel="noopener" href="{{ watch.url.replace('source:','') }}"></a> |                     <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a> | ||||||
|                     <a href="{{url_for('form_share_put_watch', uuid=watch.uuid)}}"><img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread.svg')}}" /></a> |                     <a class="link-spread" href="{{url_for('form_share_put_watch', uuid=watch.uuid)}}"><img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="icon icon-spread" /></a> | ||||||
|  |  | ||||||
|                     {%if watch.fetch_backend == "html_webdriver" %}<img style="height: 1em; display:inline-block;" src="{{url_for('static_content', group='images', filename='Google-Chrome-icon.png')}}" />{% endif %} |                     {%if watch.fetch_backend == "html_webdriver" %}<img style="height: 1em; display:inline-block;" src="{{url_for('static_content', group='images', filename='Google-Chrome-icon.png')}}" />{% endif %} | ||||||
|  |  | ||||||
| @@ -61,14 +96,14 @@ | |||||||
|                     <div class="fetch-error">{{ watch.last_error }}</div> |                     <div class="fetch-error">{{ watch.last_error }}</div> | ||||||
|                     {% endif %} |                     {% endif %} | ||||||
|                     {% if watch.last_notification_error is defined and watch.last_notification_error != False %} |                     {% if watch.last_notification_error is defined and watch.last_notification_error != False %} | ||||||
|                     <div class="fetch-error notification-error">{{ watch.last_notification_error }}</div> |                     <div class="fetch-error notification-error"><a href="{{url_for('notification_logs')}}">{{ watch.last_notification_error }}</a></div> | ||||||
|                     {% endif %} |                     {% endif %} | ||||||
|                     {% if not active_tag %} |                     {% if not active_tag %} | ||||||
|                     <span class="watch-tag-list">{{ watch.tag}}</span> |                     <span class="watch-tag-list">{{ watch.tag}}</span> | ||||||
|                     {% endif %} |                     {% endif %} | ||||||
|                 </td> |                 </td> | ||||||
|                 <td class="last-checked">{{watch|format_last_checked_time}}</td> |                 <td class="last-checked">{{watch|format_last_checked_time|safe}}</td> | ||||||
|                 <td class="last-changed">{% if watch.history_n >=2 and watch.last_changed %} |                 <td class="last-changed">{% if watch.history_n >=2 and watch.last_changed >0 %} | ||||||
|                     {{watch.last_changed|format_timestamp_timeago}} |                     {{watch.last_changed|format_timestamp_timeago}} | ||||||
|                     {% else %} |                     {% else %} | ||||||
|                     Not yet |                     Not yet | ||||||
| @@ -76,13 +111,13 @@ | |||||||
|                 </td> |                 </td> | ||||||
|                 <td> |                 <td> | ||||||
|                     <a {% if watch.uuid in queued_uuids %}disabled="true"{% endif %} href="{{ url_for('form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" |                     <a {% if watch.uuid in queued_uuids %}disabled="true"{% endif %} href="{{ url_for('form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" | ||||||
|                        class="recheck pure-button button-small pure-button-primary">{% if watch.uuid in queued_uuids %}Queued{% else %}Recheck{% endif %}</a> |                        class="recheck pure-button pure-button-primary">{% if watch.uuid in queued_uuids %}Queued{% else %}Recheck{% endif %}</a> | ||||||
|                     <a href="{{ url_for('edit_page', uuid=watch.uuid)}}" class="pure-button button-small pure-button-primary">Edit</a> |                     <a href="{{ url_for('edit_page', uuid=watch.uuid)}}" class="pure-button pure-button-primary">Edit</a> | ||||||
|                     {% if watch.history_n >= 2 %} |                     {% if watch.history_n >= 2 %} | ||||||
|                     <a href="{{ url_for('diff_history_page', uuid=watch.uuid) }}" target="{{watch.uuid}}" class="pure-button button-small pure-button-primary diff-link">Diff</a> |                     <a href="{{ url_for('diff_history_page', uuid=watch.uuid) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">Diff</a> | ||||||
|                     {% else %} |                     {% else %} | ||||||
|                         {% if watch.history_n == 1 %} |                         {% if watch.history_n == 1 or (watch.history_n ==0 and watch.error_text_ctime )%} | ||||||
|                             <a href="{{ url_for('preview_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button button-small pure-button-primary">Preview</a> |                             <a href="{{ url_for('preview_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary">Preview</a> | ||||||
|                         {% endif %} |                         {% endif %} | ||||||
|                     {% endif %} |                     {% endif %} | ||||||
|                 </td> |                 </td> | ||||||
| @@ -104,6 +139,11 @@ | |||||||
|                 <a href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a> |                 <a href="{{ url_for('rss', tag=active_tag , token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a> | ||||||
|             </li> |             </li> | ||||||
|         </ul> |         </ul> | ||||||
|  |         {# WIP for pagination, disabled for now | ||||||
|  |          {{ pagination(sorted_watches,3, pagination_page) }} | ||||||
|  |          #} | ||||||
|  |  | ||||||
|     </div> |     </div> | ||||||
|  |     </form> | ||||||
| </div> | </div> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -32,6 +32,8 @@ def app(request): | |||||||
|     """Create application for the tests.""" |     """Create application for the tests.""" | ||||||
|     datastore_path = "./test-datastore" |     datastore_path = "./test-datastore" | ||||||
|  |  | ||||||
|  |     # So they don't delay in fetching | ||||||
|  |     os.environ["MINIMUM_SECONDS_RECHECK_TIME"] = "0" | ||||||
|     try: |     try: | ||||||
|         os.mkdir(datastore_path) |         os.mkdir(datastore_path) | ||||||
|     except FileExistsError: |     except FileExistsError: | ||||||
| @@ -39,7 +41,7 @@ def app(request): | |||||||
|  |  | ||||||
|     cleanup(datastore_path) |     cleanup(datastore_path) | ||||||
|  |  | ||||||
|     app_config = {'datastore_path': datastore_path} |     app_config = {'datastore_path': datastore_path, 'disable_checkver' : True} | ||||||
|     cleanup(app_config['datastore_path']) |     cleanup(app_config['datastore_path']) | ||||||
|     datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], include_default_watches=False) |     datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], include_default_watches=False) | ||||||
|     app = changedetection_app(app_config, datastore) |     app = changedetection_app(app_config, datastore) | ||||||
|   | |||||||
| @@ -2,10 +2,10 @@ | |||||||
|  |  | ||||||
| import time | import time | ||||||
| from flask import url_for | from flask import url_for | ||||||
| from ..util import live_server_setup | from ..util import live_server_setup, wait_for_all_checks | ||||||
| import logging | import logging | ||||||
|  |  | ||||||
|  | # Requires playwright to be installed | ||||||
| def test_fetch_webdriver_content(client, live_server): | def test_fetch_webdriver_content(client, live_server): | ||||||
|     live_server_setup(live_server) |     live_server_setup(live_server) | ||||||
|  |  | ||||||
| @@ -29,14 +29,8 @@ def test_fetch_webdriver_content(client, live_server): | |||||||
|  |  | ||||||
|     assert b"1 Imported" in res.data |     assert b"1 Imported" in res.data | ||||||
|     time.sleep(3) |     time.sleep(3) | ||||||
|     attempt = 0 |  | ||||||
|     while attempt < 20: |     wait_for_all_checks(client) | ||||||
|         res = client.get(url_for("index")) |  | ||||||
|         if not b'Checking now' in res.data: |  | ||||||
|             break |  | ||||||
|         logging.getLogger().info("Waiting for check to not say 'Checking now'..") |  | ||||||
|         time.sleep(3) |  | ||||||
|         attempt += 1 |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     res = client.get( |     res = client.get( | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								changedetectionio/tests/proxy_list/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | |||||||
|  | """Tests for the app.""" | ||||||
|  |  | ||||||
							
								
								
									
										14
									
								
								changedetectionio/tests/proxy_list/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,14 @@ | |||||||
|  | #!/usr/bin/python3 | ||||||
|  |  | ||||||
|  | from .. import conftest | ||||||
|  |  | ||||||
|  | #def pytest_addoption(parser): | ||||||
|  | #    parser.addoption("--url_suffix", action="store", default="identifier for request") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | #def pytest_generate_tests(metafunc): | ||||||
|  | #    # This is called for every test. Only get/set command line arguments | ||||||
|  | #    # if the argument is specified in the list of test "fixturenames". | ||||||
|  | #    option_value = metafunc.config.option.url_suffix | ||||||
|  | #    if 'url_suffix' in metafunc.fixturenames and option_value is not None: | ||||||
|  | #        metafunc.parametrize("url_suffix", [option_value]) | ||||||
							
								
								
									
										10
									
								
								changedetectionio/tests/proxy_list/proxies.json-example
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | |||||||
|  | { | ||||||
|  |   "proxy-one": { | ||||||
|  |     "label": "One", | ||||||
|  |     "url": "http://127.0.0.1:3128" | ||||||
|  |   }, | ||||||
|  |   "proxy-two": { | ||||||
|  |     "label": "two", | ||||||
|  |     "url": "http://127.0.0.1:3129" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										41
									
								
								changedetectionio/tests/proxy_list/squid.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,41 @@ | |||||||
|  | acl localnet src 0.0.0.1-0.255.255.255  # RFC 1122 "this" network (LAN) | ||||||
|  | acl localnet src 10.0.0.0/8             # RFC 1918 local private network (LAN) | ||||||
|  | acl localnet src 100.64.0.0/10          # RFC 6598 shared address space (CGN) | ||||||
|  | acl localnet src 169.254.0.0/16         # RFC 3927 link-local (directly plugged) machines | ||||||
|  | acl localnet src 172.16.0.0/12          # RFC 1918 local private network (LAN) | ||||||
|  | acl localnet src 192.168.0.0/16         # RFC 1918 local private network (LAN) | ||||||
|  | acl localnet src fc00::/7               # RFC 4193 local private network range | ||||||
|  | acl localnet src fe80::/10              # RFC 4291 link-local (directly plugged) machines | ||||||
|  | acl localnet src 159.65.224.174 | ||||||
|  | acl SSL_ports port 443 | ||||||
|  | acl Safe_ports port 80          # http | ||||||
|  | acl Safe_ports port 21          # ftp | ||||||
|  | acl Safe_ports port 443         # https | ||||||
|  | acl Safe_ports port 70          # gopher | ||||||
|  | acl Safe_ports port 210         # wais | ||||||
|  | acl Safe_ports port 1025-65535  # unregistered ports | ||||||
|  | acl Safe_ports port 280         # http-mgmt | ||||||
|  | acl Safe_ports port 488         # gss-http | ||||||
|  | acl Safe_ports port 591         # filemaker | ||||||
|  | acl Safe_ports port 777         # multiling http | ||||||
|  | acl CONNECT method CONNECT | ||||||
|  |  | ||||||
|  | http_access deny !Safe_ports | ||||||
|  | http_access deny CONNECT !SSL_ports | ||||||
|  | http_access allow localhost manager | ||||||
|  | http_access deny manager | ||||||
|  | http_access allow localhost | ||||||
|  | http_access allow localnet | ||||||
|  | http_access deny all | ||||||
|  | http_port 3128 | ||||||
|  | coredump_dir /var/spool/squid | ||||||
|  | refresh_pattern ^ftp:           1440    20%     10080 | ||||||
|  | refresh_pattern ^gopher:        1440    0%      1440 | ||||||
|  | refresh_pattern -i (/cgi-bin/|\?) 0     0%      0 | ||||||
|  | refresh_pattern \/(Packages|Sources)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims | ||||||
|  | refresh_pattern \/Release(|\.gpg)$ 0 0% 0 refresh-ims | ||||||
|  | refresh_pattern \/InRelease$ 0 0% 0 refresh-ims | ||||||
|  | refresh_pattern \/(Translation-.*)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims | ||||||
|  | refresh_pattern .               0       20%     4320 | ||||||
|  | logfile_rotate 0 | ||||||
|  |  | ||||||
							
								
								
									
										38
									
								
								changedetectionio/tests/proxy_list/test_multiple_proxy.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,38 @@ | |||||||
|  | #!/usr/bin/python3 | ||||||
|  |  | ||||||
|  | import time | ||||||
|  | from flask import url_for | ||||||
|  | from ..util import live_server_setup | ||||||
|  |  | ||||||
|  | def test_preferred_proxy(client, live_server): | ||||||
|  |     time.sleep(1) | ||||||
|  |     live_server_setup(live_server) | ||||||
|  |     time.sleep(1) | ||||||
|  |     url = "http://chosen.changedetection.io" | ||||||
|  |  | ||||||
|  |     res = client.post( | ||||||
|  |         url_for("import_page"), | ||||||
|  |         # Because a URL wont show in squid/proxy logs due it being SSLed | ||||||
|  |         # Use plain HTTP or a specific domain-name here | ||||||
|  |         data={"urls": url}, | ||||||
|  |         follow_redirects=True | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     assert b"1 Imported" in res.data | ||||||
|  |  | ||||||
|  |     time.sleep(2) | ||||||
|  |     res = client.post( | ||||||
|  |         url_for("edit_page", uuid="first"), | ||||||
|  |         data={ | ||||||
|  |                 "include_filters": "", | ||||||
|  |                 "fetch_backend": "html_requests", | ||||||
|  |                 "headers": "", | ||||||
|  |                 "proxy": "proxy-two", | ||||||
|  |                 "tag": "", | ||||||
|  |                 "url": url, | ||||||
|  |               }, | ||||||
|  |         follow_redirects=True | ||||||
|  |     ) | ||||||
|  |     assert b"Updated watch." in res.data | ||||||
|  |     time.sleep(2) | ||||||
|  |     # Now the request should appear in the second-squid logs | ||||||
							
								
								
									
										19
									
								
								changedetectionio/tests/proxy_list/test_proxy.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | |||||||
|  | #!/usr/bin/python3 | ||||||
|  |  | ||||||
|  | import time | ||||||
|  | from flask import url_for | ||||||
|  | from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||||
|  |  | ||||||
|  | # just make a request, we will grep in the docker logs to see it actually got called | ||||||
|  | def test_check_basic_change_detection_functionality(client, live_server): | ||||||
|  |     live_server_setup(live_server) | ||||||
|  |     res = client.post( | ||||||
|  |         url_for("import_page"), | ||||||
|  |         # Because a URL wont show in squid/proxy logs due it being SSLed | ||||||
|  |         # Use plain HTTP or a specific domain-name here | ||||||
|  |         data={"urls": "http://one.changedetection.io"}, | ||||||
|  |         follow_redirects=True | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     assert b"1 Imported" in res.data | ||||||
|  |     time.sleep(3) | ||||||
| @@ -19,7 +19,6 @@ def test_check_access_control(app, client): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         assert b"Password protection enabled." in res.data |         assert b"Password protection enabled." in res.data | ||||||
|         assert b"LOG OUT" not in res.data |  | ||||||
|  |  | ||||||
|         # Check we hit the login |         # Check we hit the login | ||||||
|         res = c.get(url_for("index"), follow_redirects=True) |         res = c.get(url_for("index"), follow_redirects=True) | ||||||
| @@ -38,7 +37,40 @@ def test_check_access_control(app, client): | |||||||
|             follow_redirects=True |             follow_redirects=True | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         # Yes we are correctly logged in | ||||||
|         assert b"LOG OUT" in res.data |         assert b"LOG OUT" in res.data | ||||||
|  |  | ||||||
|  |         # 598 - Password should be set and not accidently removed | ||||||
|  |         res = c.post( | ||||||
|  |             url_for("settings_page"), | ||||||
|  |             data={ | ||||||
|  |                   "requests-time_between_check-minutes": 180, | ||||||
|  |                   'application-fetch_backend': "html_requests"}, | ||||||
|  |             follow_redirects=True | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         res = c.get(url_for("logout"), | ||||||
|  |             follow_redirects=True) | ||||||
|  |  | ||||||
|  |         res = c.get(url_for("settings_page"), | ||||||
|  |             follow_redirects=True) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         assert b"Login" in res.data | ||||||
|  |  | ||||||
|  |         res = c.get(url_for("login")) | ||||||
|  |         assert b"Login" in res.data | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         res = c.post( | ||||||
|  |             url_for("login"), | ||||||
|  |             data={"password": "foobar"}, | ||||||
|  |             follow_redirects=True | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Yes we are correctly logged in | ||||||
|  |         assert b"LOG OUT" in res.data | ||||||
|  |  | ||||||
|         res = c.get(url_for("settings_page")) |         res = c.get(url_for("settings_page")) | ||||||
|  |  | ||||||
|         # Menu should be available now |         # Menu should be available now | ||||||
|   | |||||||
| @@ -95,6 +95,8 @@ def test_api_simple(client, live_server): | |||||||
|     assert watch_uuid in json.loads(res.data).keys() |     assert watch_uuid in json.loads(res.data).keys() | ||||||
|     before_recheck_info = json.loads(res.data)[watch_uuid] |     before_recheck_info = json.loads(res.data)[watch_uuid] | ||||||
|     assert before_recheck_info['last_checked'] != 0 |     assert before_recheck_info['last_checked'] != 0 | ||||||
|  |     #705 `last_changed` should be zero on the first check | ||||||
|  |     assert before_recheck_info['last_changed'] == 0 | ||||||
|     assert before_recheck_info['title'] == 'My test URL' |     assert before_recheck_info['title'] == 'My test URL' | ||||||
|  |  | ||||||
|     set_modified_response() |     set_modified_response() | ||||||
| @@ -145,6 +147,16 @@ def test_api_simple(client, live_server): | |||||||
|     # @todo how to handle None/default global values? |     # @todo how to handle None/default global values? | ||||||
|     assert watch['history_n'] == 2, "Found replacement history section, which is in its own API" |     assert watch['history_n'] == 2, "Found replacement history section, which is in its own API" | ||||||
|  |  | ||||||
|  |     # basic systeminfo check | ||||||
|  |     res = client.get( | ||||||
|  |         url_for("systeminfo"), | ||||||
|  |         headers={'x-api-key': api_key}, | ||||||
|  |     ) | ||||||
|  |     info = json.loads(res.data) | ||||||
|  |     assert info.get('watch_count') == 1 | ||||||
|  |     assert info.get('uptime') > 0.5 | ||||||
|  |  | ||||||
|  |  | ||||||
|     # Finally delete the watch |     # Finally delete the watch | ||||||
|     res = client.delete( |     res = client.delete( | ||||||
|         url_for("watch", uuid=watch_uuid), |         url_for("watch", uuid=watch_uuid), | ||||||
|   | |||||||
| @@ -19,17 +19,16 @@ def test_basic_auth(client, live_server): | |||||||
|         follow_redirects=True |         follow_redirects=True | ||||||
|     ) |     ) | ||||||
|     assert b"1 Imported" in res.data |     assert b"1 Imported" in res.data | ||||||
|  |     time.sleep(1) | ||||||
|  |  | ||||||
|     # Check form validation |     # Check form validation | ||||||
|     res = client.post( |     res = client.post( | ||||||
|         url_for("edit_page", uuid="first"), |         url_for("edit_page", uuid="first"), | ||||||
|         data={"css_filter": "", "url": test_url, "tag": "", "headers": "", 'fetch_backend': "html_requests"}, |         data={"include_filters": "", "url": test_url, "tag": "", "headers": "", 'fetch_backend': "html_requests"}, | ||||||
|         follow_redirects=True |         follow_redirects=True | ||||||
|     ) |     ) | ||||||
|     assert b"Updated watch." in res.data |     assert b"Updated watch." in res.data | ||||||
|  |  | ||||||
|     # Trigger a check |  | ||||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) |  | ||||||
|     time.sleep(1) |     time.sleep(1) | ||||||
|     res = client.get( |     res = client.get( | ||||||
|         url_for("preview_page", uuid="first"), |         url_for("preview_page", uuid="first"), | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ | |||||||
| import time | import time | ||||||
| from flask import url_for | from flask import url_for | ||||||
| from urllib.request import urlopen | from urllib.request import urlopen | ||||||
| from .util import set_original_response, set_modified_response, live_server_setup | from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks | ||||||
|  |  | ||||||
| sleep_time_for_fetch_thread = 3 | sleep_time_for_fetch_thread = 3 | ||||||
|  |  | ||||||
| @@ -36,7 +36,7 @@ def test_check_basic_change_detection_functionality(client, live_server): | |||||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) |         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||||
|  |  | ||||||
|         # Give the thread time to pick it up |         # Give the thread time to pick it up | ||||||
|         time.sleep(sleep_time_for_fetch_thread) |         wait_for_all_checks(client) | ||||||
|  |  | ||||||
|         # It should report nothing found (no new 'unviewed' class) |         # It should report nothing found (no new 'unviewed' class) | ||||||
|         res = client.get(url_for("index")) |         res = client.get(url_for("index")) | ||||||
| @@ -69,7 +69,7 @@ def test_check_basic_change_detection_functionality(client, live_server): | |||||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) |     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||||
|     assert b'1 watches are queued for rechecking.' in res.data |     assert b'1 watches are queued for rechecking.' in res.data | ||||||
|  |  | ||||||
|     time.sleep(sleep_time_for_fetch_thread) |     wait_for_all_checks(client) | ||||||
|  |  | ||||||
|     # Now something should be ready, indicated by having a 'unviewed' class |     # Now something should be ready, indicated by having a 'unviewed' class | ||||||
|     res = client.get(url_for("index")) |     res = client.get(url_for("index")) | ||||||
| @@ -90,14 +90,22 @@ def test_check_basic_change_detection_functionality(client, live_server): | |||||||
|     res = client.get(url_for("diff_history_page", uuid="first")) |     res = client.get(url_for("diff_history_page", uuid="first")) | ||||||
|     assert b'Compare newest' in res.data |     assert b'Compare newest' in res.data | ||||||
|  |  | ||||||
|     time.sleep(2) |     # Check the [preview] pulls the right one | ||||||
|  |     res = client.get( | ||||||
|  |         url_for("preview_page", uuid="first"), | ||||||
|  |         follow_redirects=True | ||||||
|  |     ) | ||||||
|  |     assert b'which has this one new line' in res.data | ||||||
|  |     assert b'Which is across multiple lines' not in res.data | ||||||
|  |  | ||||||
|  |     wait_for_all_checks(client) | ||||||
|  |  | ||||||
|     # Do this a few times.. ensures we dont accidently set the status |     # Do this a few times.. ensures we dont accidently set the status | ||||||
|     for n in range(2): |     for n in range(2): | ||||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) |         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||||
|  |  | ||||||
|         # Give the thread time to pick it up |         # Give the thread time to pick it up | ||||||
|         time.sleep(sleep_time_for_fetch_thread) |         wait_for_all_checks(client) | ||||||
|  |  | ||||||
|         # It should report nothing found (no new 'unviewed' class) |         # It should report nothing found (no new 'unviewed' class) | ||||||
|         res = client.get(url_for("index")) |         res = client.get(url_for("index")) | ||||||
| @@ -117,7 +125,7 @@ def test_check_basic_change_detection_functionality(client, live_server): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) |     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||||
|     time.sleep(sleep_time_for_fetch_thread) |     wait_for_all_checks(client) | ||||||
|  |  | ||||||
|     res = client.get(url_for("index")) |     res = client.get(url_for("index")) | ||||||
|     assert b'unviewed' in res.data |     assert b'unviewed' in res.data | ||||||
|   | |||||||