mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 14:47:21 +00:00 
			
		
		
		
	Compare commits
	
		
			64 Commits
		
	
	
		
			save-last-
			...
			dynamic-ur
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | da9e1a0f26 | ||
|   | a4f5cf6ca3 | ||
|   | 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 | 
							
								
								
									
										14
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.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. | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										55
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | 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 | ||||||
|  |  | ||||||
|  |         - 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
									
									
								
							
							
						
						
									
										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 |  | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -5,13 +5,14 @@ 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 \ | ||||||
|  |     libssl-dev \ | ||||||
|     libxslt-dev \ |     libxslt-dev \ | ||||||
|     zlib1g-dev \ |     make \ | ||||||
|     g++ |     zlib1g-dev | ||||||
|  |  | ||||||
| RUN mkdir /install | RUN mkdir /install | ||||||
| WORKDIR /install | WORKDIR /install | ||||||
| @@ -22,9 +23,14 @@ 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.24 \ | RUN pip install --target=/dependencies playwright~=1.26 \ | ||||||
|     || 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." | ||||||
|  |  | ||||||
|  |  | ||||||
|  | RUN pip install --target=/dependencies jq~=1.3 \ | ||||||
|  |     || echo "WARN: Failed to install JQ. The application can still run, but the Jq: filter option will be disabled." | ||||||
|  |  | ||||||
|  |  | ||||||
| # Final image stage | # Final image stage | ||||||
| FROM python:3.8-slim | FROM python:3.8-slim | ||||||
|  |  | ||||||
| @@ -58,6 +64,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,6 +2,7 @@ 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 * | ||||||
| include changedetection.py | include changedetection.py | ||||||
| global-exclude *.pyc | global-exclude *.pyc | ||||||
| global-exclude node_modules | global-exclude node_modules | ||||||
|   | |||||||
| @@ -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. | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										56
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										56
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,9 +1,8 @@ | |||||||
| ## Web Site Change Detection, Monitoring and Notification. | ## Web Site Change Detection, Monitoring and Notification. | ||||||
|  |  | ||||||
| [**Try our $6.99/month subscription - Unlimited checks and watches!**](https://lemonade.changedetection.io/start) | Live your data-life pro-actively, track website content changes and receive notifications via Discord, Email, Slack, Telegram and 70+ 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) | [<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) | ||||||
|  |  | ||||||
| @@ -11,13 +10,16 @@ | |||||||
|  |  | ||||||
| Know when important content changes, we support notifications via Discord, Telegram, Home-Assistant, Slack, Email and 70+ more | Know when important content changes, we support notifications via Discord, Telegram, Home-Assistant, Slack, Email and 70+ more | ||||||
|  |  | ||||||
| [**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!_ | [**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!_ | ||||||
|  |  | ||||||
|  | - Chrome browser included. | ||||||
|  | - Super fast, no registration needed setup. | ||||||
|  | - Start watching and receiving change notifications instantly. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Easily see what changed, examine by word, line, or individual character. | ||||||
|  |  | ||||||
| - Automatic Updates, Automatic Backups, No Heroku "paused application", don't miss a change! | <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 " /> | ||||||
| - Javascript browser included |  | ||||||
| - Unlimited checks and watches! |  | ||||||
|  |  | ||||||
|  |  | ||||||
| #### Example use cases | #### Example use cases | ||||||
| @@ -40,16 +42,23 @@ Know when important content changes, we support notifications via Discord, Teleg | |||||||
| - 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) | ||||||
|  |  | ||||||
| _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>_ | ||||||
|  |  | ||||||
|  | #### 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 | ||||||
|  | - Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration) | ||||||
|  |  | ||||||
|  | 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. | ||||||
|  |  | ||||||
| ## Screenshots | ## Screenshots | ||||||
|  |  | ||||||
| ### Examine differences in content. |  | ||||||
|  |  | ||||||
| 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 " /> |  | ||||||
|  |  | ||||||
| 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/ | ||||||
|  |  | ||||||
| ### Filter by elements using the Visual Selector tool. | ### Filter by elements using the Visual Selector tool. | ||||||
| @@ -112,8 +121,8 @@ 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. |  | ||||||
|  |  | ||||||
|  | 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`.) | (We support LXML `re:test`, `re:math` and `re:replace`.) | ||||||
|  |  | ||||||
| ## Notifications | ## Notifications | ||||||
| @@ -142,7 +151,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 +159,20 @@ 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 | ||||||
|  |  | ||||||
|  | Note: `jq` library must be added separately (`pip3 install jq`) | ||||||
|  |  | ||||||
|  |  | ||||||
| ### 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,7 +182,7 @@ 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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,16 +1,5 @@ | |||||||
| #!/usr/bin/python3 | #!/usr/bin/python3 | ||||||
|  |  | ||||||
|  |  | ||||||
| # @todo logging |  | ||||||
| # @todo extra options for url like , verify=False etc. |  | ||||||
| # @todo enable https://urllib3.readthedocs.io/en/latest/user-guide.html#ssl as option? |  | ||||||
| # @todo option for interval day/6 hour/etc |  | ||||||
| # @todo on change detected, config for calling some API |  | ||||||
| # @todo fetch title into json |  | ||||||
| # https://distill.io/features |  | ||||||
| # proxy per check |  | ||||||
| #  - flask_cors, itsdangerous,MarkupSafe |  | ||||||
|  |  | ||||||
| import datetime | import datetime | ||||||
| import os | import os | ||||||
| import queue | import queue | ||||||
| @@ -44,7 +33,7 @@ from flask_wtf import CSRFProtect | |||||||
| from changedetectionio import html_tools | from changedetectionio import html_tools | ||||||
| from changedetectionio.api import api_v1 | from changedetectionio.api import api_v1 | ||||||
|  |  | ||||||
| __version__ = '0.39.18' | __version__ = '0.39.20.4' | ||||||
|  |  | ||||||
| datastore = None | datastore = None | ||||||
|  |  | ||||||
| @@ -205,6 +194,9 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|     watch_api.add_resource(api_v1.Watch, '/api/v1/watch/<string:uuid>', |     watch_api.add_resource(api_v1.Watch, '/api/v1/watch/<string:uuid>', | ||||||
|                            resource_class_kwargs={'datastore': datastore, 'update_q': update_q}) |                            resource_class_kwargs={'datastore': datastore, 'update_q': update_q}) | ||||||
|  |  | ||||||
|  |     watch_api.add_resource(api_v1.SystemInfo, '/api/v1/systeminfo', | ||||||
|  |                            resource_class_kwargs={'datastore': datastore, 'update_q': update_q}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -503,7 +495,7 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|         from changedetectionio import fetch_site_status |         from changedetectionio import fetch_site_status | ||||||
|  |  | ||||||
|         # Get the most recent one |         # Get the most recent one | ||||||
|         newest_history_key = datastore.get_val(uuid, 'newest_history_key') |         newest_history_key = datastore.data['watching'][uuid].get('newest_history_key') | ||||||
|  |  | ||||||
|         # 0 means that theres only one, so that there should be no 'unviewed' history available |         # 0 means that theres only one, so that there should be no 'unviewed' history available | ||||||
|         if newest_history_key == 0: |         if newest_history_key == 0: | ||||||
| @@ -552,16 +544,13 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|         # be sure we update with a copy instead of accidently editing the live object by reference |         # be sure we update with a copy instead of accidently editing the live object by reference | ||||||
|         default = deepcopy(datastore.data['watching'][uuid]) |         default = deepcopy(datastore.data['watching'][uuid]) | ||||||
|  |  | ||||||
|         # Show system wide default if nothing configured |  | ||||||
|         if datastore.data['watching'][uuid]['fetch_backend'] is None: |  | ||||||
|             default['fetch_backend'] = datastore.data['settings']['application']['fetch_backend'] |  | ||||||
|  |  | ||||||
|         # Show system wide default if nothing configured |         # Show system wide default if nothing configured | ||||||
|         if all(value == 0 or value == None for value in datastore.data['watching'][uuid]['time_between_check'].values()): |         if all(value == 0 or value == None for value in datastore.data['watching'][uuid]['time_between_check'].values()): | ||||||
|             default['time_between_check'] = deepcopy(datastore.data['settings']['requests']['time_between_check']) |             default['time_between_check'] = deepcopy(datastore.data['settings']['requests']['time_between_check']) | ||||||
|  |  | ||||||
|         # Defaults for proxy choice |         # Defaults for proxy choice | ||||||
|         if datastore.proxy_list is not None:  # When enabled |         if datastore.proxy_list is not None:  # When enabled | ||||||
|  |             # @todo | ||||||
|             # Radio needs '' not None, or incase that the chosen one no longer exists |             # Radio needs '' not None, or incase that the chosen one no longer exists | ||||||
|             if default['proxy'] is None or not any(default['proxy'] in tup for tup in datastore.proxy_list): |             if default['proxy'] is None or not any(default['proxy'] in tup for tup in datastore.proxy_list): | ||||||
|                 default['proxy'] = '' |                 default['proxy'] = '' | ||||||
| @@ -575,7 +564,10 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|             # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead |             # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead | ||||||
|             del form.proxy |             del form.proxy | ||||||
|         else: |         else: | ||||||
|             form.proxy.choices = [('', 'Default')] + datastore.proxy_list |             form.proxy.choices = [('', 'Default')] | ||||||
|  |             for p in datastore.proxy_list: | ||||||
|  |                 form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label']))) | ||||||
|  |  | ||||||
|  |  | ||||||
|         if request.method == 'POST' and form.validate(): |         if request.method == 'POST' and form.validate(): | ||||||
|             extra_update_obj = {} |             extra_update_obj = {} | ||||||
| @@ -598,10 +590,8 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|             if form.fetch_backend.data == datastore.data['settings']['application']['fetch_backend']: |             if form.fetch_backend.data == datastore.data['settings']['application']['fetch_backend']: | ||||||
|                 extra_update_obj['fetch_backend'] = None |                 extra_update_obj['fetch_backend'] = None | ||||||
|  |  | ||||||
|             # Notification URLs |  | ||||||
|             datastore.data['watching'][uuid]['notification_urls'] = form.notification_urls.data |  | ||||||
|  |  | ||||||
|             # Ignore text |              # Ignore text | ||||||
|             form_ignore_text = form.ignore_text.data |             form_ignore_text = form.ignore_text.data | ||||||
|             datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text |             datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text | ||||||
|  |  | ||||||
| @@ -649,18 +639,27 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|             # Only works reliably with Playwright |             # Only works reliably with Playwright | ||||||
|             visualselector_enabled = os.getenv('PLAYWRIGHT_DRIVER_URL', False) and default['fetch_backend'] == 'html_webdriver' |             visualselector_enabled = os.getenv('PLAYWRIGHT_DRIVER_URL', False) and default['fetch_backend'] == 'html_webdriver' | ||||||
|  |  | ||||||
|  |             # JQ is difficult to install on windows and must be manually added (outside requirements.txt) | ||||||
|  |             jq_support = True | ||||||
|  |             try: | ||||||
|  |                 import jq | ||||||
|  |             except ModuleNotFoundError: | ||||||
|  |                 jq_support = False | ||||||
|  |  | ||||||
|             output = render_template("edit.html", |             output = render_template("edit.html", | ||||||
|                                      uuid=uuid, |  | ||||||
|                                      watch=datastore.data['watching'][uuid], |  | ||||||
|                                      form=form, |  | ||||||
|                                      has_empty_checktime=using_default_check_time, |  | ||||||
|                                      using_global_webdriver_wait=default['webdriver_delay'] is None, |  | ||||||
|                                      current_base_url=datastore.data['settings']['application']['base_url'], |                                      current_base_url=datastore.data['settings']['application']['base_url'], | ||||||
|                                      emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), |                                      emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||||
|  |                                      form=form, | ||||||
|  |                                      has_default_notification_urls=True if len(datastore.data['settings']['application']['notification_urls']) else False, | ||||||
|  |                                      has_empty_checktime=using_default_check_time, | ||||||
|  |                                      jq_support=jq_support, | ||||||
|  |                                      playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False), | ||||||
|  |                                      settings_application=datastore.data['settings']['application'], | ||||||
|  |                                      using_global_webdriver_wait=default['webdriver_delay'] is None, | ||||||
|  |                                      uuid=uuid, | ||||||
|                                      visualselector_data_is_ready=visualselector_data_is_ready, |                                      visualselector_data_is_ready=visualselector_data_is_ready, | ||||||
|                                      visualselector_enabled=visualselector_enabled, |                                      visualselector_enabled=visualselector_enabled, | ||||||
|                                      playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False) |                                      watch=datastore.data['watching'][uuid], | ||||||
|                                      ) |                                      ) | ||||||
|  |  | ||||||
|         return output |         return output | ||||||
| @@ -672,26 +671,34 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|  |  | ||||||
|         default = deepcopy(datastore.data['settings']) |         default = deepcopy(datastore.data['settings']) | ||||||
|         if datastore.proxy_list is not None: |         if datastore.proxy_list is not None: | ||||||
|  |             available_proxies = list(datastore.proxy_list.keys()) | ||||||
|             # When enabled |             # When enabled | ||||||
|             system_proxy = datastore.data['settings']['requests']['proxy'] |             system_proxy = datastore.data['settings']['requests']['proxy'] | ||||||
|             # In the case it doesnt exist anymore |             # In the case it doesnt exist anymore | ||||||
|             if not any([system_proxy in tup for tup in datastore.proxy_list]): |             if not system_proxy in available_proxies: | ||||||
|                 system_proxy = None |                 system_proxy = None | ||||||
|  |  | ||||||
|             default['requests']['proxy'] = system_proxy if system_proxy is not None else datastore.proxy_list[0][0] |             default['requests']['proxy'] = system_proxy if system_proxy is not None else available_proxies[0] | ||||||
|             # Used by the form handler to keep or remove the proxy settings |             # Used by the form handler to keep or remove the proxy settings | ||||||
|             default['proxy_list'] = datastore.proxy_list |             default['proxy_list'] = available_proxies[0] | ||||||
|  |  | ||||||
|  |  | ||||||
|         # Don't use form.data on POST so that it doesnt overrid the checkbox status from the POST status |         # Don't use form.data on POST so that it doesnt overrid the checkbox status from the POST status | ||||||
|         form = forms.globalSettingsForm(formdata=request.form if request.method == 'POST' else None, |         form = forms.globalSettingsForm(formdata=request.form if request.method == 'POST' else None, | ||||||
|                                         data=default |                                         data=default | ||||||
|                                         ) |                                         ) | ||||||
|  |  | ||||||
|  |         # Remove the last option 'System default' | ||||||
|  |         form.application.form.notification_format.choices.pop() | ||||||
|  |  | ||||||
|         if datastore.proxy_list is None: |         if datastore.proxy_list is None: | ||||||
|             # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead |             # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead | ||||||
|             del form.requests.form.proxy |             del form.requests.form.proxy | ||||||
|         else: |         else: | ||||||
|             form.requests.form.proxy.choices = datastore.proxy_list |             form.requests.form.proxy.choices = [] | ||||||
|  |             for p in datastore.proxy_list: | ||||||
|  |                 form.requests.form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label']))) | ||||||
|  |  | ||||||
|  |  | ||||||
|         if request.method == 'POST': |         if request.method == 'POST': | ||||||
|             # Password unset is a GET, but we can lock the session to a salted env password to always need the password |             # Password unset is a GET, but we can lock the session to a salted env password to always need the password | ||||||
| @@ -732,7 +739,8 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|                                  current_base_url = datastore.data['settings']['application']['base_url'], |                                  current_base_url = datastore.data['settings']['application']['base_url'], | ||||||
|                                  hide_remove_pass=os.getenv("SALTED_PASS", False), |                                  hide_remove_pass=os.getenv("SALTED_PASS", False), | ||||||
|                                  api_key=datastore.data['settings']['application'].get('api_access_token'), |                                  api_key=datastore.data['settings']['application'].get('api_access_token'), | ||||||
|                                  emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False)) |                                  emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||||
|  |                                  settings_application=datastore.data['settings']['application']) | ||||||
|  |  | ||||||
|         return output |         return output | ||||||
|  |  | ||||||
| @@ -811,8 +819,10 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|  |  | ||||||
|         newest_file = history[dates[-1]] |         newest_file = history[dates[-1]] | ||||||
|  |  | ||||||
|  |         # Read as binary and force decode as UTF-8 | ||||||
|  |         # Windows may fail decode in python if we just use 'r' mode (chardet decode exception) | ||||||
|         try: |         try: | ||||||
|             with open(newest_file, 'r') as f: |             with open(newest_file, 'r', encoding='utf-8', errors='ignore') as f: | ||||||
|                 newest_version_file_contents = f.read() |                 newest_version_file_contents = f.read() | ||||||
|         except Exception as e: |         except Exception as e: | ||||||
|             newest_version_file_contents = "Unable to read {}.\n".format(newest_file) |             newest_version_file_contents = "Unable to read {}.\n".format(newest_file) | ||||||
| @@ -825,7 +835,7 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|             previous_file = history[dates[-2]] |             previous_file = history[dates[-2]] | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             with open(previous_file, 'r') as f: |             with open(previous_file, 'r', encoding='utf-8', errors='ignore') as f: | ||||||
|                 previous_version_file_contents = f.read() |                 previous_version_file_contents = f.read() | ||||||
|         except Exception as e: |         except Exception as e: | ||||||
|             previous_version_file_contents = "Unable to read {}.\n".format(previous_file) |             previous_version_file_contents = "Unable to read {}.\n".format(previous_file) | ||||||
| @@ -902,7 +912,7 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|         timestamp = list(watch.history.keys())[-1] |         timestamp = list(watch.history.keys())[-1] | ||||||
|         filename = watch.history[timestamp] |         filename = watch.history[timestamp] | ||||||
|         try: |         try: | ||||||
|             with open(filename, 'r') as f: |             with open(filename, 'r', encoding='utf-8', errors='ignore') as f: | ||||||
|                 tmp = f.readlines() |                 tmp = f.readlines() | ||||||
|  |  | ||||||
|                 # Get what needs to be highlighted |                 # Get what needs to be highlighted | ||||||
| @@ -1199,7 +1209,7 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|                     datastore.delete(uuid.strip()) |                     datastore.delete(uuid.strip()) | ||||||
|             flash("{} watches deleted".format(len(uuids))) |             flash("{} watches deleted".format(len(uuids))) | ||||||
|  |  | ||||||
|         if (op == 'pause'): |         elif (op == 'pause'): | ||||||
|             for uuid in uuids: |             for uuid in uuids: | ||||||
|                 uuid = uuid.strip() |                 uuid = uuid.strip() | ||||||
|                 if datastore.data['watching'].get(uuid): |                 if datastore.data['watching'].get(uuid): | ||||||
| @@ -1207,13 +1217,40 @@ def changedetection_app(config=None, datastore_o=None): | |||||||
|  |  | ||||||
|             flash("{} watches paused".format(len(uuids))) |             flash("{} watches paused".format(len(uuids))) | ||||||
|  |  | ||||||
|         if (op == 'unpause'): |         elif (op == 'unpause'): | ||||||
|             for uuid in uuids: |             for uuid in uuids: | ||||||
|                 uuid = uuid.strip() |                 uuid = uuid.strip() | ||||||
|                 if datastore.data['watching'].get(uuid): |                 if datastore.data['watching'].get(uuid): | ||||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = False |                     datastore.data['watching'][uuid.strip()]['paused'] = False | ||||||
|             flash("{} watches unpaused".format(len(uuids))) |             flash("{} watches unpaused".format(len(uuids))) | ||||||
|  |  | ||||||
|  |         elif (op == 'mute'): | ||||||
|  |             for uuid in uuids: | ||||||
|  |                 uuid = uuid.strip() | ||||||
|  |                 if datastore.data['watching'].get(uuid): | ||||||
|  |                     datastore.data['watching'][uuid.strip()]['notification_muted'] = True | ||||||
|  |             flash("{} watches muted".format(len(uuids))) | ||||||
|  |  | ||||||
|  |         elif (op == 'unmute'): | ||||||
|  |             for uuid in uuids: | ||||||
|  |                 uuid = uuid.strip() | ||||||
|  |                 if datastore.data['watching'].get(uuid): | ||||||
|  |                     datastore.data['watching'][uuid.strip()]['notification_muted'] = False | ||||||
|  |             flash("{} watches un-muted".format(len(uuids))) | ||||||
|  |  | ||||||
|  |         elif (op == 'notification-default'): | ||||||
|  |             from changedetectionio.notification import ( | ||||||
|  |                 default_notification_format_for_watch | ||||||
|  |             ) | ||||||
|  |             for uuid in uuids: | ||||||
|  |                 uuid = uuid.strip() | ||||||
|  |                 if datastore.data['watching'].get(uuid): | ||||||
|  |                     datastore.data['watching'][uuid.strip()]['notification_title'] = None | ||||||
|  |                     datastore.data['watching'][uuid.strip()]['notification_body'] = None | ||||||
|  |                     datastore.data['watching'][uuid.strip()]['notification_urls'] = [] | ||||||
|  |                     datastore.data['watching'][uuid.strip()]['notification_format'] = default_notification_format_for_watch | ||||||
|  |             flash("{} watches set to use default notification settings".format(len(uuids))) | ||||||
|  |  | ||||||
|         return redirect(url_for('index')) |         return redirect(url_for('index')) | ||||||
|  |  | ||||||
|     @app.route("/api/share-url", methods=['GET']) |     @app.route("/api/share-url", methods=['GET']) | ||||||
| @@ -1351,6 +1388,8 @@ def ticker_thread_check_time_launch_checks(): | |||||||
|     import random |     import random | ||||||
|     from changedetectionio import update_worker |     from changedetectionio import update_worker | ||||||
|  |  | ||||||
|  |     proxy_last_called_time = {} | ||||||
|  |  | ||||||
|     recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 20)) |     recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 20)) | ||||||
|     print("System env MINIMUM_SECONDS_RECHECK_TIME", recheck_time_minimum_seconds) |     print("System env MINIMUM_SECONDS_RECHECK_TIME", recheck_time_minimum_seconds) | ||||||
|  |  | ||||||
| @@ -1411,10 +1450,30 @@ def ticker_thread_check_time_launch_checks(): | |||||||
|                 if watch.jitter_seconds == 0: |                 if watch.jitter_seconds == 0: | ||||||
|                     watch.jitter_seconds = random.uniform(-abs(jitter), jitter) |                     watch.jitter_seconds = random.uniform(-abs(jitter), jitter) | ||||||
|  |  | ||||||
|  |  | ||||||
|             seconds_since_last_recheck = now - watch['last_checked'] |             seconds_since_last_recheck = now - watch['last_checked'] | ||||||
|  |  | ||||||
|             if seconds_since_last_recheck >= (threshold + watch.jitter_seconds) and seconds_since_last_recheck >= recheck_time_minimum_seconds: |             if seconds_since_last_recheck >= (threshold + watch.jitter_seconds) and seconds_since_last_recheck >= recheck_time_minimum_seconds: | ||||||
|                 if not uuid in running_uuids and uuid not in [q_uuid for p,q_uuid in update_q.queue]: |                 if not uuid in running_uuids and uuid not in [q_uuid for p,q_uuid in update_q.queue]: | ||||||
|  |  | ||||||
|  |                     # Proxies can be set to have a limit on seconds between which they can be called | ||||||
|  |                     watch_proxy = datastore.get_preferred_proxy_for_watch(uuid=uuid) | ||||||
|  |                     if watch_proxy and watch_proxy in list(datastore.proxy_list.keys()): | ||||||
|  |                         # Proxy may also have some threshold minimum | ||||||
|  |                         proxy_list_reuse_time_minimum = int(datastore.proxy_list.get(watch_proxy, {}).get('reuse_time_minimum', 0)) | ||||||
|  |                         if proxy_list_reuse_time_minimum: | ||||||
|  |                             proxy_last_used_time = proxy_last_called_time.get(watch_proxy, 0) | ||||||
|  |                             time_since_proxy_used = int(time.time() - proxy_last_used_time) | ||||||
|  |                             if time_since_proxy_used < proxy_list_reuse_time_minimum: | ||||||
|  |                                 # Not enough time difference reached, skip this watch | ||||||
|  |                                 print("> Skipped UUID {} using proxy '{}', not enough time between proxy requests {}s/{}s".format(uuid, | ||||||
|  |                                                                                                                          watch_proxy, | ||||||
|  |                                                                                                                          time_since_proxy_used, | ||||||
|  |                                                                                                                          proxy_list_reuse_time_minimum)) | ||||||
|  |                                 continue | ||||||
|  |                             else: | ||||||
|  |                                 # Record the last used time | ||||||
|  |                                 proxy_last_called_time[watch_proxy] = int(time.time()) | ||||||
|  |  | ||||||
|                     # Use Epoch time as priority, so we get a "sorted" PriorityQueue, but we can still push a priority 1 into it. |                     # Use Epoch time as priority, so we get a "sorted" PriorityQueue, but we can still push a priority 1 into it. | ||||||
|                     priority = int(time.time()) |                     priority = int(time.time()) | ||||||
|                     print( |                     print( | ||||||
|   | |||||||
| @@ -122,3 +122,37 @@ class CreateWatch(Resource): | |||||||
|             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 | ||||||
|   | |||||||
| @@ -102,6 +102,14 @@ 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. | ||||||
|  |     @app.after_request | ||||||
|  |     def hide_referrer(response): | ||||||
|  |         if 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 | ||||||
|   | |||||||
| @@ -316,6 +316,7 @@ class base_html_playwright(Fetcher): | |||||||
|         import playwright._impl._api_types |         import playwright._impl._api_types | ||||||
|         from playwright._impl._api_types import Error, TimeoutError |         from playwright._impl._api_types import Error, TimeoutError | ||||||
|         response = None |         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) | ||||||
|  |  | ||||||
| @@ -373,8 +374,11 @@ class base_html_playwright(Fetcher): | |||||||
|                 print("response object was none") |                 print("response object was none") | ||||||
|                 raise EmptyReply(url=url, status_code=None) |                 raise EmptyReply(url=url, status_code=None) | ||||||
|  |  | ||||||
|             # Bug 2(?) Set the viewport size AFTER loading the page |  | ||||||
|             page.set_viewport_size({"width": 1280, "height": 1024}) |             # Removed browser-set-size, seemed to be needed to make screenshots work reliably in older playwright versions | ||||||
|  |             # Was causing exceptions like 'waiting for page but content is changing' etc | ||||||
|  |             # https://www.browserstack.com/docs/automate/playwright/change-browser-window-size 1280x720 should be the default | ||||||
|  |                          | ||||||
|             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 | ||||||
|             time.sleep(extra_wait) |             time.sleep(extra_wait) | ||||||
|  |  | ||||||
| @@ -398,6 +402,13 @@ class base_html_playwright(Fetcher): | |||||||
|  |  | ||||||
|                     raise JSActionExceptions(status_code=response.status, screenshot=error_screenshot, message=str(e), url=url) |                     raise JSActionExceptions(status_code=response.status, screenshot=error_screenshot, message=str(e), url=url) | ||||||
|  |  | ||||||
|  |                 else: | ||||||
|  |                     # JS eval was run, now we also wait some time if possible to let the page settle | ||||||
|  |                     if self.render_extract_delay: | ||||||
|  |                         page.wait_for_timeout(self.render_extract_delay * 1000) | ||||||
|  |  | ||||||
|  |             page.wait_for_timeout(500) | ||||||
|  |  | ||||||
|             self.content = page.content() |             self.content = page.content() | ||||||
|             self.status_code = response.status |             self.status_code = response.status | ||||||
|             self.headers = response.all_headers() |             self.headers = response.all_headers() | ||||||
| @@ -514,8 +525,6 @@ class base_html_webdriver(Fetcher): | |||||||
|             # Selenium doesn't automatically wait for actions as good as Playwright, so wait again |             # 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))) |             self.driver.implicitly_wait(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5))) | ||||||
|  |  | ||||||
|         self.screenshot = self.driver.get_screenshot_as_png() |  | ||||||
|  |  | ||||||
|         # @todo - how to check this? is it possible? |         # @todo - how to check this? is it possible? | ||||||
|         self.status_code = 200 |         self.status_code = 200 | ||||||
|         # @todo somehow we should try to get this working for WebDriver |         # @todo somehow we should try to get this working for WebDriver | ||||||
| @@ -526,6 +535,8 @@ 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 | ||||||
| @@ -564,6 +575,11 @@ class html_requests(Fetcher): | |||||||
|             ignore_status_codes=False, |             ignore_status_codes=False, | ||||||
|             current_css_filter=None): |             current_css_filter=None): | ||||||
|  |  | ||||||
|  |         # 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 = {} |         proxies = {} | ||||||
|  |  | ||||||
|         # Allows override the proxy on a per-request basis |         # Allows override the proxy on a per-request basis | ||||||
|   | |||||||
| @@ -20,34 +20,6 @@ class perform_site_check(): | |||||||
|         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 |  | ||||||
|     # if watch.proxy use that |  | ||||||
|     # fetcher.proxy_override = watch.proxy or main config proxy |  | ||||||
|     # Allows override the proxy on a per-request basis |  | ||||||
|     # ALWAYS use the first one is nothing selected |  | ||||||
|  |  | ||||||
|     def set_proxy_from_list(self, watch): |  | ||||||
|         proxy_args = None |  | ||||||
|         if self.datastore.proxy_list is None: |  | ||||||
|             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: |  | ||||||
|             system_proxy = self.datastore.data['settings']['requests']['proxy'] |  | ||||||
|             # 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 |  | ||||||
|         if proxy_args is None: |  | ||||||
|             proxy_args = self.datastore.proxy_list[0][0] |  | ||||||
|  |  | ||||||
|         return proxy_args |  | ||||||
|  |  | ||||||
|     # Doesn't look like python supports forward slash auto enclosure in re.findall |     # Doesn't look like python supports forward slash auto enclosure in re.findall | ||||||
|     # So convert it to inline flag "foobar(?i)" type configuration |     # So convert it to inline flag "foobar(?i)" type configuration | ||||||
|     def forward_slash_enclosed_regex_to_options(self, regex): |     def forward_slash_enclosed_regex_to_options(self, regex): | ||||||
| @@ -63,13 +35,13 @@ class perform_site_check(): | |||||||
|  |  | ||||||
|  |  | ||||||
|     def run(self, uuid): |     def run(self, uuid): | ||||||
|         timestamp = int(time.time())  # used for storage etc too |  | ||||||
|  |  | ||||||
|         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] |         watch = 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['url'], re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False): | ||||||
| @@ -80,7 +52,7 @@ 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 =self.datastore.data['watching'][uuid].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 = self.datastore.data['settings']['headers'].copy() | ||||||
| @@ -92,10 +64,12 @@ 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') |  | ||||||
|  |         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) |         ignore_status_codes = self.datastore.data['watching'][uuid].get('ignore_status_codes', False) | ||||||
|  |  | ||||||
|         # source: support |         # source: support | ||||||
| @@ -112,9 +86,13 @@ class perform_site_check(): | |||||||
|             # 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) | ||||||
| @@ -165,8 +143,9 @@ class perform_site_check(): | |||||||
|             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) |             if any(prefix in css_filter_rule for prefix in json_filter_prefixes): | ||||||
|  |                 stripped_text_from_html = html_tools.extract_json_as_string(content=fetcher.content, json_filter=css_filter_rule) | ||||||
|                 is_html = False |                 is_html = False | ||||||
|  |  | ||||||
|         if is_html or is_source: |         if is_html or is_source: | ||||||
|   | |||||||
| @@ -303,6 +303,25 @@ 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): | ||||||
| @@ -314,14 +333,14 @@ class quickWatchForm(Form): | |||||||
|  |  | ||||||
| # 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 watchForm(commonSettingsForm): | class watchForm(commonSettingsForm): | ||||||
|  |  | ||||||
| @@ -355,6 +374,8 @@ class watchForm(commonSettingsForm): | |||||||
|     filter_failure_notification_send = BooleanField( |     filter_failure_notification_send = BooleanField( | ||||||
|         'Send a notification when the filter can no longer be found on the page', default=False) |         'Send a notification when the filter can no longer be found on the page', default=False) | ||||||
|  |  | ||||||
|  |     notification_muted = BooleanField('Notifications Muted / Off', default=False) | ||||||
|  |  | ||||||
|     def validate(self, **kwargs): |     def validate(self, **kwargs): | ||||||
|         if not super().validate(): |         if not super().validate(): | ||||||
|             return False |             return False | ||||||
|   | |||||||
| @@ -1,11 +1,11 @@ | |||||||
| import json |  | ||||||
| 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 | ||||||
|  |  | ||||||
| class FilterNotFoundInResponse(ValueError): | class FilterNotFoundInResponse(ValueError): | ||||||
|     def __init__(self, msg): |     def __init__(self, msg): | ||||||
| @@ -79,19 +79,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: | ||||||
| @@ -103,16 +119,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') | ||||||
| @@ -131,7 +147,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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -13,10 +13,6 @@ class model(dict): | |||||||
|             'watching': {}, |             'watching': {}, | ||||||
|             'settings': { |             'settings': { | ||||||
|                 'headers': { |                 'headers': { | ||||||
|                     'User-Agent': 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'), |  | ||||||
|                     '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': int(getenv("DEFAULT_SETTINGS_REQUESTS_TIMEOUT", "45")),  # Default 45 seconds |                     'timeout': int(getenv("DEFAULT_SETTINGS_REQUESTS_TIMEOUT", "45")),  # Default 45 seconds | ||||||
|   | |||||||
| @@ -6,9 +6,7 @@ minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 60) | |||||||
| mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7} | 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, |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -32,9 +30,9 @@ class model(dict): | |||||||
|             'ignore_text': [],  # List of text to ignore when calculating the comparison checksum |             'ignore_text': [],  # List of text to ignore when calculating the comparison checksum | ||||||
|             # Custom notification content |             # Custom notification content | ||||||
|             'notification_urls': [],  # List of URLs to add to the notification Queue (Usually AppRise) |             'notification_urls': [],  # List of URLs to add to the notification Queue (Usually AppRise) | ||||||
|             'notification_title': default_notification_title, |             'notification_title': None, | ||||||
|             'notification_body': default_notification_body, |             'notification_body': None, | ||||||
|             'notification_format': default_notification_format, |             'notification_format': default_notification_format_for_watch, | ||||||
|             'notification_muted': False, |             'notification_muted': False, | ||||||
|             'css_filter': '', |             'css_filter': '', | ||||||
|             'last_error': False, |             'last_error': False, | ||||||
| @@ -89,6 +87,16 @@ class model(dict): | |||||||
|             print ("> Creating data dir {}".format(target_path)) |             print ("> Creating data dir {}".format(target_path)) | ||||||
|             os.mkdir(target_path) |             os.mkdir(target_path) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def link(self): | ||||||
|  |         url = self.get('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']) | ||||||
|  |             return str(jinja2_env.from_string(url).render()) | ||||||
|  |         return url | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def label(self): |     def label(self): | ||||||
|         # Used for sorting |         # Used for sorting | ||||||
| @@ -120,7 +128,10 @@ class model(dict): | |||||||
|         if os.path.isfile(fname): |         if os.path.isfile(fname): | ||||||
|             logging.debug("Reading history index " + 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) | ||||||
|  |                         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] | ||||||
| @@ -153,28 +164,30 @@ class model(dict): | |||||||
|         import uuid |         import uuid | ||||||
|         import logging |         import logging | ||||||
|  |  | ||||||
|         output_path = "{}/{}".format(self.__datastore_path, self['uuid']) |         output_path = os.path.join(self.__datastore_path, self['uuid']) | ||||||
|  |  | ||||||
|         self.ensure_data_dir_exists() |         self.ensure_data_dir_exists() | ||||||
|  |         snapshot_fname = os.path.join(output_path, str(uuid.uuid4())) | ||||||
|  |  | ||||||
|         snapshot_fname = "{}/{}.stripped.txt".format(output_path, uuid.uuid4()) |  | ||||||
|         logging.debug("Saving history text {}".format(snapshot_fname)) |         logging.debug("Saving history text {}".format(snapshot_fname)) | ||||||
|  |  | ||||||
|  |         # 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(snapshot_fname, 'wb') as f: |         with open(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(output_path, "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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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,13 @@ export BASE_URL="https://really-unique-domain.io" | |||||||
| pytest tests/test_notification.py | pytest tests/test_notification.py | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ## JQ + JSON: filter test | ||||||
|  | # jq is not available on windows and we should just test it when the package is installed | ||||||
|  | # this will re-test with jq support | ||||||
|  | pip3 install jq~=1.3 | ||||||
|  | pytest tests/test_jsonpath_jq_selector.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 | ||||||
|  |  | ||||||
| @@ -38,7 +47,9 @@ 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.24 | 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 | ||||||
| @@ -48,4 +59,48 @@ pytest tests/test_errorhandling.py | |||||||
| pytest tests/visualselector/test_fetch_data.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 | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								changedetectionio/static/images/notice.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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 | 
| @@ -30,4 +30,11 @@ $(document).ready(function() { | |||||||
|     }); |     }); | ||||||
|     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(); | ||||||
|  |     }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -565,3 +565,16 @@ ul { | |||||||
|  |  | ||||||
| .checkbox-uuid > * { | .checkbox-uuid > * { | ||||||
|   vertical-align: middle; } |   vertical-align: middle; } | ||||||
|  |  | ||||||
|  | .inline-warning { | ||||||
|  |   border: 1px solid #ff3300; | ||||||
|  |   padding: 0.5rem; | ||||||
|  |   border-radius: 5px; | ||||||
|  |   color: #ff3300; } | ||||||
|  |   .inline-warning > span { | ||||||
|  |     display: inline-block; | ||||||
|  |     vertical-align: middle; } | ||||||
|  |   .inline-warning img.inline-warning-icon { | ||||||
|  |     display: inline; | ||||||
|  |     height: 26px; | ||||||
|  |     vertical-align: middle; } | ||||||
|   | |||||||
| @@ -786,3 +786,21 @@ ul { | |||||||
|     vertical-align: middle; |     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 #ff3300; | ||||||
|  |   padding: 0.5rem; | ||||||
|  |   border-radius: 5px; | ||||||
|  |   color: #ff3300; | ||||||
|  | } | ||||||
| @@ -30,14 +30,14 @@ class ChangeDetectionStore: | |||||||
|     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,8 +81,6 @@ 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='http://www.quotationspage.com/random.php', tag='test') |  | ||||||
|                 self.add_watch(url='https://news.ycombinator.com/', tag='Tech news') |                 self.add_watch(url='https://news.ycombinator.com/', tag='Tech news') | ||||||
|                 self.add_watch(url='https://changedetection.io/CHANGELOG.txt', tag='changedetection.io') |                 self.add_watch(url='https://changedetection.io/CHANGELOG.txt', tag='changedetection.io') | ||||||
|  |  | ||||||
| @@ -113,9 +111,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) | ||||||
|  |  | ||||||
| @@ -244,10 +240,6 @@ 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 clear_watch_history(self, uuid): |     def clear_watch_history(self, uuid): | ||||||
|         import pathlib |         import pathlib | ||||||
| @@ -441,20 +433,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 | ||||||
| @@ -539,4 +553,33 @@ class ChangeDetectionStore: | |||||||
|                 del(watch['last_changed']) |                 del(watch['last_changed']) | ||||||
|             except: |             except: | ||||||
|                 continue |                 continue | ||||||
|         return |         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] | ||||||
|   | |||||||
| @@ -1,13 +1,14 @@ | |||||||
|  |  | ||||||
| {% 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> | ||||||
| @@ -26,15 +27,16 @@ | |||||||
|                         </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"> | ||||||
| @@ -94,7 +96,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> | ||||||
|   | |||||||
| @@ -40,7 +40,8 @@ | |||||||
|                 <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") }} | ||||||
| @@ -77,6 +78,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 %} | ||||||
| @@ -135,10 +137,20 @@ User-Agent: wonderbra 1.0") }} | |||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <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> | ||||||
|  |                     <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> | ||||||
| @@ -173,8 +185,16 @@ User-Agent: wonderbra 1.0") }} | |||||||
|                         <span class="pure-form-message-inline"> |                         <span class="pure-form-message-inline"> | ||||||
|                     <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>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, |                         <li>XPath - Limit text to this XPath rule, simply start with a forward-slash, | ||||||
|                             <ul> |                             <ul> | ||||||
|                                 <li>Example:  <code>//*[contains(@class, 'sametext')]</code> or <code>xpath://*[contains(@class, 'sametext')]</code>, <a |                                 <li>Example:  <code>//*[contains(@class, 'sametext')]</code> or <code>xpath://*[contains(@class, 'sametext')]</code>, <a | ||||||
| @@ -183,7 +203,7 @@ User-Agent: wonderbra 1.0") }} | |||||||
|                             </ul> |                             </ul> | ||||||
|                             </li> |                             </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> | ||||||
|   | |||||||
| @@ -57,6 +57,7 @@ | |||||||
|          </br> |          </br> | ||||||
|          {% if is_html_webdriver %} |          {% if is_html_webdriver %} | ||||||
|            {% if screenshot %} |            {% if screenshot %} | ||||||
|  |              <div class="snapshot-age">{{watch.snapshot_screenshot_ctime|format_timestamp_timeago}}</div> | ||||||
|              <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/> |              <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/> | ||||||
|            {% else %} |            {% else %} | ||||||
|               No screenshot available just yet! Try rechecking the page. |               No screenshot available just yet! Try rechecking the page. | ||||||
|   | |||||||
| @@ -60,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> | ||||||
| @@ -87,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> | ||||||
| @@ -99,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"> | ||||||
|   | |||||||
| @@ -30,6 +30,9 @@ | |||||||
|     <div id="checkbox-operations"> |     <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="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="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> |         <button class="pure-button button-secondary button-xsmall" style="background: #dd4242; font-size: 70%" name="op" value="delete">Delete</button> | ||||||
|     </div> |     </div> | ||||||
|     <div> |     <div> | ||||||
| @@ -76,11 +79,15 @@ | |||||||
|                 {% if watch.uuid in queued_uuids %}queued{% endif %}"> |                 {% if watch.uuid in queued_uuids %}queued{% endif %}"> | ||||||
|                 <td class="inline checkbox-uuid" ><input name="uuids"  type="checkbox" value="{{ watch.uuid}} "/> <span>{{ loop.index }}</span></td> |                 <td class="inline checkbox-uuid" ><input name="uuids"  type="checkbox" value="{{ watch.uuid}} "/> <span>{{ loop.index }}</span></td> | ||||||
|                 <td class="inline watch-controls"> |                 <td class="inline watch-controls"> | ||||||
|                     <a class="state-{{'on' if watch.paused }}" 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"/></a> |                     {% 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"/></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"/></a> | ||||||
|  |                     {% endif %} | ||||||
|                     <a class="state-{{'on' if watch.notification_muted}}" 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"/></a> |                     <a class="state-{{'on' if watch.notification_muted}}" 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"/></a> | ||||||
|                 </td> |                 </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 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> | ||||||
|  |  | ||||||
|                     {%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 %} | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								changedetectionio/tests/proxy_list/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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={ | ||||||
|  |                 "css_filter": "", | ||||||
|  |                 "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
									
								
							
							
						
						
									
										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) | ||||||
| @@ -147,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), | ||||||
|   | |||||||
							
								
								
									
										33
									
								
								changedetectionio/tests/test_jinja2.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								changedetectionio/tests/test_jinja2.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | |||||||
|  | #!/usr/bin/python3 | ||||||
|  |  | ||||||
|  | import time | ||||||
|  | from flask import url_for | ||||||
|  | from .util import live_server_setup | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # If there was only a change in the whitespacing, then we shouldnt have a change detected | ||||||
|  | def test_jinja2_in_url_query(client, live_server): | ||||||
|  |     live_server_setup(live_server) | ||||||
|  |  | ||||||
|  |     # Give the endpoint time to spin up | ||||||
|  |     time.sleep(1) | ||||||
|  |  | ||||||
|  |     # Add our URL to the import page | ||||||
|  |     test_url = url_for('test_return_query', _external=True) | ||||||
|  |  | ||||||
|  |     # because url_for() will URL-encode the var, but we dont here | ||||||
|  |     full_url = "{}?{}".format(test_url, | ||||||
|  |                               "date={% now 'Europe/Berlin', '%Y' %}.{% now 'Europe/Berlin', '%m' %}.{% now 'Europe/Berlin', '%d' %}", ) | ||||||
|  |     res = client.post( | ||||||
|  |         url_for("form_quick_watch_add"), | ||||||
|  |         data={"url": full_url, "tag": "test"}, | ||||||
|  |         follow_redirects=True | ||||||
|  |     ) | ||||||
|  |     assert b"Watch added" in res.data | ||||||
|  |     time.sleep(3) | ||||||
|  |     # It should report nothing found (no new 'unviewed' class) | ||||||
|  |     res = client.get( | ||||||
|  |         url_for("preview_page", uuid="first"), | ||||||
|  |         follow_redirects=True | ||||||
|  |     ) | ||||||
|  |     assert b'date=2' in res.data | ||||||
| @@ -2,10 +2,15 @@ | |||||||
| # coding=utf-8 | # coding=utf-8 | ||||||
| 
 | 
 | ||||||
| import time | import time | ||||||
| from flask import url_for | from flask import url_for, escape | ||||||
| from . util import live_server_setup | from . util import live_server_setup | ||||||
| import pytest | import pytest | ||||||
|  | jq_support = True | ||||||
| 
 | 
 | ||||||
|  | try: | ||||||
|  |     import jq | ||||||
|  | except ModuleNotFoundError: | ||||||
|  |     jq_support = False | ||||||
| 
 | 
 | ||||||
| def test_setup(live_server): | def test_setup(live_server): | ||||||
|     live_server_setup(live_server) |     live_server_setup(live_server) | ||||||
| @@ -36,16 +41,28 @@ and it can also be repeated | |||||||
|     from .. import html_tools |     from .. import html_tools | ||||||
| 
 | 
 | ||||||
|     # See that we can find the second <script> one, which is not broken, and matches our filter |     # See that we can find the second <script> one, which is not broken, and matches our filter | ||||||
|     text = html_tools.extract_json_as_string(content, "$.offers.price") |     text = html_tools.extract_json_as_string(content, "json:$.offers.price") | ||||||
|     assert text == "23.5" |     assert text == "23.5" | ||||||
| 
 | 
 | ||||||
|     text = html_tools.extract_json_as_string('{"id":5}', "$.id") |     # also check for jq | ||||||
|  |     if jq_support: | ||||||
|  |         text = html_tools.extract_json_as_string(content, "jq:.offers.price") | ||||||
|  |         assert text == "23.5" | ||||||
|  | 
 | ||||||
|  |         text = html_tools.extract_json_as_string('{"id":5}', "jq:.id") | ||||||
|  |         assert text == "5" | ||||||
|  | 
 | ||||||
|  |     text = html_tools.extract_json_as_string('{"id":5}', "json:$.id") | ||||||
|     assert text == "5" |     assert text == "5" | ||||||
| 
 | 
 | ||||||
|     # When nothing at all is found, it should throw JSONNOTFound |     # When nothing at all is found, it should throw JSONNOTFound | ||||||
|     # Which is caught and shown to the user in the watch-overview table |     # Which is caught and shown to the user in the watch-overview table | ||||||
|     with pytest.raises(html_tools.JSONNotFound) as e_info: |     with pytest.raises(html_tools.JSONNotFound) as e_info: | ||||||
|         html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "$.id") |         html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "json:$.id") | ||||||
|  | 
 | ||||||
|  |     if jq_support: | ||||||
|  |         with pytest.raises(html_tools.JSONNotFound) as e_info: | ||||||
|  |             html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "jq:.id") | ||||||
| 
 | 
 | ||||||
| def set_original_ext_response(): | def set_original_ext_response(): | ||||||
|     data = """ |     data = """ | ||||||
| @@ -66,6 +83,7 @@ def set_original_ext_response(): | |||||||
| 
 | 
 | ||||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: |     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||||
|         f.write(data) |         f.write(data) | ||||||
|  |     return None | ||||||
| 
 | 
 | ||||||
| def set_modified_ext_response(): | def set_modified_ext_response(): | ||||||
|     data = """ |     data = """ | ||||||
| @@ -86,6 +104,7 @@ def set_modified_ext_response(): | |||||||
| 
 | 
 | ||||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: |     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||||
|         f.write(data) |         f.write(data) | ||||||
|  |     return None | ||||||
| 
 | 
 | ||||||
| def set_original_response(): | def set_original_response(): | ||||||
|     test_return_data = """ |     test_return_data = """ | ||||||
| @@ -184,10 +203,10 @@ def test_check_json_without_filter(client, live_server): | |||||||
|     assert b'"<b>' in res.data |     assert b'"<b>' in res.data | ||||||
|     assert res.data.count(b'{\n') >= 2 |     assert res.data.count(b'{\n') >= 2 | ||||||
| 
 | 
 | ||||||
|  |     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||||
|  |     assert b'Deleted' in res.data | ||||||
| 
 | 
 | ||||||
| def test_check_json_filter(client, live_server): | def check_json_filter(json_filter, client, live_server): | ||||||
|     json_filter = 'json:boss.name' |  | ||||||
| 
 |  | ||||||
|     set_original_response() |     set_original_response() | ||||||
| 
 | 
 | ||||||
|     # Give the endpoint time to spin up |     # Give the endpoint time to spin up | ||||||
| @@ -226,7 +245,7 @@ def test_check_json_filter(client, live_server): | |||||||
|     res = client.get( |     res = client.get( | ||||||
|         url_for("edit_page", uuid="first"), |         url_for("edit_page", uuid="first"), | ||||||
|     ) |     ) | ||||||
|     assert bytes(json_filter.encode('utf-8')) in res.data |     assert bytes(escape(json_filter).encode('utf-8')) in res.data | ||||||
| 
 | 
 | ||||||
|     # Trigger a check |     # Trigger a check | ||||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) |     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||||
| @@ -252,10 +271,17 @@ def test_check_json_filter(client, live_server): | |||||||
|     # And #462 - check we see the proper utf-8 string there |     # And #462 - check we see the proper utf-8 string there | ||||||
|     assert "Örnsköldsvik".encode('utf-8') in res.data |     assert "Örnsköldsvik".encode('utf-8') in res.data | ||||||
| 
 | 
 | ||||||
|  |     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||||
|  |     assert b'Deleted' in res.data | ||||||
| 
 | 
 | ||||||
| def test_check_json_filter_bool_val(client, live_server): | def test_check_jsonpath_filter(client, live_server): | ||||||
|     json_filter = "json:$['available']" |     check_json_filter('json:boss.name', client, live_server) | ||||||
| 
 | 
 | ||||||
|  | def test_check_jq_filter(client, live_server): | ||||||
|  |     if jq_support: | ||||||
|  |         check_json_filter('jq:.boss.name', client, live_server) | ||||||
|  | 
 | ||||||
|  | def check_json_filter_bool_val(json_filter, client, live_server): | ||||||
|     set_original_response() |     set_original_response() | ||||||
| 
 | 
 | ||||||
|     # Give the endpoint time to spin up |     # Give the endpoint time to spin up | ||||||
| @@ -304,14 +330,22 @@ def test_check_json_filter_bool_val(client, live_server): | |||||||
|     # But the change should be there, tho its hard to test the change was detected because it will show old and new versions |     # But the change should be there, tho its hard to test the change was detected because it will show old and new versions | ||||||
|     assert b'false' in res.data |     assert b'false' in res.data | ||||||
| 
 | 
 | ||||||
|  |     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||||
|  |     assert b'Deleted' in res.data | ||||||
|  | 
 | ||||||
|  | def test_check_jsonpath_filter_bool_val(client, live_server): | ||||||
|  |     check_json_filter_bool_val("json:$['available']", client, live_server) | ||||||
|  | 
 | ||||||
|  | def test_check_jq_filter_bool_val(client, live_server): | ||||||
|  |     if jq_support: | ||||||
|  |         check_json_filter_bool_val("jq:.available", client, live_server) | ||||||
|  | 
 | ||||||
| # Re #265 - Extended JSON selector test | # Re #265 - Extended JSON selector test | ||||||
| # Stuff to consider here | # Stuff to consider here | ||||||
| # - Selector should be allowed to return empty when it doesnt match (people might wait for some condition) | # - Selector should be allowed to return empty when it doesnt match (people might wait for some condition) | ||||||
| # - The 'diff' tab could show the old and new content | # - The 'diff' tab could show the old and new content | ||||||
| # - Form should let us enter a selector that doesnt (yet) match anything | # - Form should let us enter a selector that doesnt (yet) match anything | ||||||
| def test_check_json_ext_filter(client, live_server): | def check_json_ext_filter(json_filter, client, live_server): | ||||||
|     json_filter = 'json:$[?(@.status==Sold)]' |  | ||||||
| 
 |  | ||||||
|     set_original_ext_response() |     set_original_ext_response() | ||||||
| 
 | 
 | ||||||
|     # Give the endpoint time to spin up |     # Give the endpoint time to spin up | ||||||
| @@ -350,7 +384,7 @@ def test_check_json_ext_filter(client, live_server): | |||||||
|     res = client.get( |     res = client.get( | ||||||
|         url_for("edit_page", uuid="first"), |         url_for("edit_page", uuid="first"), | ||||||
|     ) |     ) | ||||||
|     assert bytes(json_filter.encode('utf-8')) in res.data |     assert bytes(escape(json_filter).encode('utf-8')) in res.data | ||||||
| 
 | 
 | ||||||
|     # Trigger a check |     # Trigger a check | ||||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) |     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||||
| @@ -376,3 +410,12 @@ def test_check_json_ext_filter(client, live_server): | |||||||
|     assert b'ForSale' not in res.data |     assert b'ForSale' not in res.data | ||||||
|     assert b'Sold' in res.data |     assert b'Sold' in res.data | ||||||
| 
 | 
 | ||||||
|  |     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||||
|  |     assert b'Deleted' in res.data | ||||||
|  | 
 | ||||||
|  | def test_check_jsonpath_ext_filter(client, live_server): | ||||||
|  |     check_json_ext_filter('json:$[?(@.status==Sold)]', client, live_server) | ||||||
|  | 
 | ||||||
|  | def test_check_jq_ext_filter(client, live_server): | ||||||
|  |     if jq_support: | ||||||
|  |         check_json_ext_filter('jq:.[] | select(.status | contains("Sold"))', client, live_server) | ||||||
| @@ -4,7 +4,13 @@ import re | |||||||
| from flask import url_for | from flask import url_for | ||||||
| from . util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup | from . util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup | ||||||
| import logging | import logging | ||||||
| from changedetectionio.notification import default_notification_body, default_notification_title |  | ||||||
|  | from changedetectionio.notification import ( | ||||||
|  |     default_notification_body, | ||||||
|  |     default_notification_format, | ||||||
|  |     default_notification_title, | ||||||
|  |     valid_notification_formats, | ||||||
|  | ) | ||||||
|  |  | ||||||
| def test_setup(live_server): | def test_setup(live_server): | ||||||
|     live_server_setup(live_server) |     live_server_setup(live_server) | ||||||
| @@ -20,9 +26,26 @@ def test_check_notification(client, live_server): | |||||||
|  |  | ||||||
|     # Re 360 - new install should have defaults set |     # Re 360 - new install should have defaults set | ||||||
|     res = client.get(url_for("settings_page")) |     res = client.get(url_for("settings_page")) | ||||||
|  |     notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') | ||||||
|  |  | ||||||
|     assert default_notification_body.encode() in res.data |     assert default_notification_body.encode() in res.data | ||||||
|     assert default_notification_title.encode() in res.data |     assert default_notification_title.encode() in res.data | ||||||
|  |  | ||||||
|  |     ##################### | ||||||
|  |     # Set this up for when we remove the notification from the watch, it should fallback with these details | ||||||
|  |     res = client.post( | ||||||
|  |         url_for("settings_page"), | ||||||
|  |         data={"application-notification_urls": notification_url, | ||||||
|  |               "application-notification_title": "fallback-title "+default_notification_title, | ||||||
|  |               "application-notification_body": "fallback-body "+default_notification_body, | ||||||
|  |               "application-notification_format": default_notification_format, | ||||||
|  |               "requests-time_between_check-minutes": 180, | ||||||
|  |               'application-fetch_backend': "html_requests"}, | ||||||
|  |         follow_redirects=True | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     assert b"Settings updated." in res.data | ||||||
|  |  | ||||||
|     # When test mode is in BASE_URL env mode, we should see this already configured |     # When test mode is in BASE_URL env mode, we should see this already configured | ||||||
|     env_base_url = os.getenv('BASE_URL', '').strip() |     env_base_url = os.getenv('BASE_URL', '').strip() | ||||||
|     if len(env_base_url): |     if len(env_base_url): | ||||||
| @@ -47,8 +70,6 @@ def test_check_notification(client, live_server): | |||||||
|  |  | ||||||
|     # Goto the edit page, add our ignore text |     # Goto the edit page, add our ignore text | ||||||
|     # Add our URL to the import page |     # Add our URL to the import page | ||||||
|     url = url_for('test_notification_endpoint', _external=True) |  | ||||||
|     notification_url = url.replace('http', 'json') |  | ||||||
|  |  | ||||||
|     print (">>>> Notification URL: "+notification_url) |     print (">>>> Notification URL: "+notification_url) | ||||||
|  |  | ||||||
| @@ -158,6 +179,30 @@ def test_check_notification(client, live_server): | |||||||
|     # be sure we see it in the output log |     # be sure we see it in the output log | ||||||
|     assert b'New ChangeDetection.io Notification - ' + test_url.encode('utf-8') in res.data |     assert b'New ChangeDetection.io Notification - ' + test_url.encode('utf-8') in res.data | ||||||
|  |  | ||||||
|  |     set_original_response() | ||||||
|  |     res = client.post( | ||||||
|  |         url_for("edit_page", uuid="first"), | ||||||
|  |         data={ | ||||||
|  |         "url": test_url, | ||||||
|  |         "tag": "my tag", | ||||||
|  |         "title": "my title", | ||||||
|  |         "notification_urls": '', | ||||||
|  |         "notification_title": '', | ||||||
|  |         "notification_body": '', | ||||||
|  |         "notification_format": default_notification_format, | ||||||
|  |         "fetch_backend": "html_requests"}, | ||||||
|  |         follow_redirects=True | ||||||
|  |     ) | ||||||
|  |     assert b"Updated watch." in res.data | ||||||
|  |  | ||||||
|  |     time.sleep(2) | ||||||
|  |  | ||||||
|  |     # Verify what was sent as a notification, this file should exist | ||||||
|  |     with open("test-datastore/notification.txt", "r") as f: | ||||||
|  |         notification_submission = f.read() | ||||||
|  |     assert "fallback-title" in notification_submission | ||||||
|  |     assert "fallback-body" in notification_submission | ||||||
|  |  | ||||||
|     # cleanup for the next |     # cleanup for the next | ||||||
|     client.get( |     client.get( | ||||||
|         url_for("form_delete", uuid="all"), |         url_for("form_delete", uuid="all"), | ||||||
| @@ -180,20 +225,20 @@ def test_notification_validation(client, live_server): | |||||||
|     assert b"Watch added" in res.data |     assert b"Watch added" in res.data | ||||||
|  |  | ||||||
|     # Re #360 some validation |     # Re #360 some validation | ||||||
|     res = client.post( | #    res = client.post( | ||||||
|         url_for("edit_page", uuid="first"), | #        url_for("edit_page", uuid="first"), | ||||||
|         data={"notification_urls": 'json://localhost/foobar', | #        data={"notification_urls": 'json://localhost/foobar', | ||||||
|               "notification_title": "", | #              "notification_title": "", | ||||||
|               "notification_body": "", | #              "notification_body": "", | ||||||
|               "notification_format": "Text", | #              "notification_format": "Text", | ||||||
|               "url": test_url, | #              "url": test_url, | ||||||
|               "tag": "my tag", | #              "tag": "my tag", | ||||||
|               "title": "my title", | #              "title": "my title", | ||||||
|               "headers": "", | #              "headers": "", | ||||||
|               "fetch_backend": "html_requests"}, | #              "fetch_backend": "html_requests"}, | ||||||
|         follow_redirects=True | #        follow_redirects=True | ||||||
|     ) | #    ) | ||||||
|     assert b"Notification Body and Title is required when a Notification URL is used" in res.data | #    assert b"Notification Body and Title is required when a Notification URL is used" in res.data | ||||||
|  |  | ||||||
|     # Now adding a wrong token should give us an error |     # Now adding a wrong token should give us an error | ||||||
|     res = client.post( |     res = client.post( | ||||||
| @@ -215,3 +260,5 @@ def test_notification_validation(client, live_server): | |||||||
|         url_for("form_delete", uuid="all"), |         url_for("form_delete", uuid="all"), | ||||||
|         follow_redirects=True |         follow_redirects=True | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -159,5 +159,10 @@ def live_server_setup(live_server): | |||||||
|         ret = " ".join([auth.username, auth.password, auth.type]) |         ret = " ".join([auth.username, auth.password, auth.type]) | ||||||
|         return ret |         return ret | ||||||
|  |  | ||||||
|  |     # Just return some GET var | ||||||
|  |     @live_server.app.route('/test-return-query', methods=['GET']) | ||||||
|  |     def test_return_query(): | ||||||
|  |         return request.query_string | ||||||
|  |  | ||||||
|     live_server.start() |     live_server.start() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,14 +7,15 @@ from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_cli | |||||||
| # Add a site in paused mode, add an invalid filter, we should still have visual selector data ready | # Add a site in paused mode, add an invalid filter, we should still have visual selector data ready | ||||||
| def test_visual_selector_content_ready(client, live_server): | def test_visual_selector_content_ready(client, live_server): | ||||||
|     import os |     import os | ||||||
|  |     import json | ||||||
|  |  | ||||||
|     assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" |     assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||||
|     live_server_setup(live_server) |     live_server_setup(live_server) | ||||||
|     time.sleep(1) |     time.sleep(1) | ||||||
|  |  | ||||||
|     # Add our URL to the import page, maybe better to use something we control? |     # Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url | ||||||
|     # We use an external URL because the docker container is too difficult to setup to connect back to the pytest socket |     test_url = "https://changedetection.io/ci-test/test-runjs.html" | ||||||
|     test_url = 'https://news.ycombinator.com' |  | ||||||
|     res = client.post( |     res = client.post( | ||||||
|         url_for("form_quick_watch_add"), |         url_for("form_quick_watch_add"), | ||||||
|         data={"url": test_url, "tag": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, |         data={"url": test_url, "tag": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, | ||||||
| @@ -24,12 +25,30 @@ def test_visual_selector_content_ready(client, live_server): | |||||||
|  |  | ||||||
|     res = client.post( |     res = client.post( | ||||||
|         url_for("edit_page", uuid="first", unpause_on_save=1), |         url_for("edit_page", uuid="first", unpause_on_save=1), | ||||||
|         data={"css_filter": ".does-not-exist", "url": test_url, "tag": "", "headers": "", 'fetch_backend': "html_webdriver"}, |         data={ | ||||||
|  |               "url": test_url, | ||||||
|  |               "tag": "", | ||||||
|  |               "headers": "", | ||||||
|  |               'fetch_backend': "html_webdriver", | ||||||
|  |               'webdriver_js_execute_code': 'document.querySelector("button[name=test-button]").click();' | ||||||
|  |         }, | ||||||
|         follow_redirects=True |         follow_redirects=True | ||||||
|     ) |     ) | ||||||
|     assert b"unpaused" in res.data |     assert b"unpaused" in res.data | ||||||
|     time.sleep(1) |     time.sleep(1) | ||||||
|     wait_for_all_checks(client) |     wait_for_all_checks(client) | ||||||
|     uuid = extract_UUID_from_client(client) |     uuid = extract_UUID_from_client(client) | ||||||
|  |  | ||||||
|  |     # Check the JS execute code before extract worked | ||||||
|  |     res = client.get( | ||||||
|  |         url_for("preview_page", uuid="first"), | ||||||
|  |         follow_redirects=True | ||||||
|  |     ) | ||||||
|  |     assert b'I smell JavaScript' in res.data | ||||||
|  |  | ||||||
|     assert os.path.isfile(os.path.join('test-datastore', uuid, 'last-screenshot.png')), "last-screenshot.png should exist" |     assert os.path.isfile(os.path.join('test-datastore', uuid, 'last-screenshot.png')), "last-screenshot.png should exist" | ||||||
|     assert os.path.isfile(os.path.join('test-datastore', uuid, 'elements.json')), "xpath elements.json data should exist" |     assert os.path.isfile(os.path.join('test-datastore', uuid, 'elements.json')), "xpath elements.json data should exist" | ||||||
|  |  | ||||||
|  |     # Open it and see if it roughly looks correct | ||||||
|  |     with open(os.path.join('test-datastore', uuid, 'elements.json'), 'r') as f: | ||||||
|  |         json.load(f) | ||||||
|   | |||||||
| @@ -11,11 +11,14 @@ from changedetectionio.html_tools import FilterNotFoundInResponse | |||||||
| # Requests for checking on a single site(watch) from a queue of watches | # Requests for checking on a single site(watch) from a queue of watches | ||||||
| # (another process inserts watches into the queue that are time-ready for checking) | # (another process inserts watches into the queue that are time-ready for checking) | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  | import sys | ||||||
|  |  | ||||||
| class update_worker(threading.Thread): | class update_worker(threading.Thread): | ||||||
|     current_uuid = None |     current_uuid = None | ||||||
|  |  | ||||||
|     def __init__(self, q, notification_q, app, datastore, *args, **kwargs): |     def __init__(self, q, notification_q, app, datastore, *args, **kwargs): | ||||||
|  |         logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) | ||||||
|         self.q = q |         self.q = q | ||||||
|         self.app = app |         self.app = app | ||||||
|         self.notification_q = notification_q |         self.notification_q = notification_q | ||||||
| @@ -26,6 +29,10 @@ class update_worker(threading.Thread): | |||||||
|  |  | ||||||
|         from changedetectionio import diff |         from changedetectionio import diff | ||||||
|  |  | ||||||
|  |         from changedetectionio.notification import ( | ||||||
|  |             default_notification_format_for_watch | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         n_object = {} |         n_object = {} | ||||||
|         watch = self.datastore.data['watching'].get(watch_uuid, False) |         watch = self.datastore.data['watching'].get(watch_uuid, False) | ||||||
|         if not watch: |         if not watch: | ||||||
| @@ -40,33 +47,27 @@ class update_worker(threading.Thread): | |||||||
|                 "History index had 2 or more, but only 1 date loaded, timestamps were not unique? maybe two of the same timestamps got written, needs more delay?" |                 "History index had 2 or more, but only 1 date loaded, timestamps were not unique? maybe two of the same timestamps got written, needs more delay?" | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|         # Did it have any notification alerts to hit? |         n_object['notification_urls'] = watch['notification_urls'] if len(watch['notification_urls']) else \ | ||||||
|         if len(watch['notification_urls']): |             self.datastore.data['settings']['application']['notification_urls'] | ||||||
|             print(">>> Notifications queued for UUID from watch {}".format(watch_uuid)) |  | ||||||
|             n_object['notification_urls'] = watch['notification_urls'] |         n_object['notification_title'] = watch['notification_title'] if watch['notification_title'] else \ | ||||||
|             n_object['notification_title'] = watch['notification_title'] |             self.datastore.data['settings']['application']['notification_title'] | ||||||
|             n_object['notification_body'] = watch['notification_body'] |  | ||||||
|             n_object['notification_format'] = watch['notification_format'] |         n_object['notification_body'] = watch['notification_body'] if watch['notification_body'] else \ | ||||||
|  |             self.datastore.data['settings']['application']['notification_body'] | ||||||
|  |  | ||||||
|  |         n_object['notification_format'] = watch['notification_format'] if watch['notification_format'] != default_notification_format_for_watch else \ | ||||||
|  |             self.datastore.data['settings']['application']['notification_format'] | ||||||
|  |  | ||||||
|         # No? maybe theres a global setting, queue them all |  | ||||||
|         elif len(self.datastore.data['settings']['application']['notification_urls']): |  | ||||||
|             print(">>> Watch notification URLs were empty, using GLOBAL notifications for UUID: {}".format(watch_uuid)) |  | ||||||
|             n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls'] |  | ||||||
|             n_object['notification_title'] = self.datastore.data['settings']['application']['notification_title'] |  | ||||||
|             n_object['notification_body'] = self.datastore.data['settings']['application']['notification_body'] |  | ||||||
|             n_object['notification_format'] = self.datastore.data['settings']['application']['notification_format'] |  | ||||||
|         else: |  | ||||||
|             print(">>> NO notifications queued, watch and global notification URLs were empty.") |  | ||||||
|  |  | ||||||
|         # Only prepare to notify if the rules above matched |         # Only prepare to notify if the rules above matched | ||||||
|         if 'notification_urls' in n_object: |         if 'notification_urls' in n_object and n_object['notification_urls']: | ||||||
|             # HTML needs linebreak, but MarkDown and Text can use a linefeed |             # HTML needs linebreak, but MarkDown and Text can use a linefeed | ||||||
|             if n_object['notification_format'] == 'HTML': |             if n_object['notification_format'] == 'HTML': | ||||||
|                 line_feed_sep = "</br>" |                 line_feed_sep = "</br>" | ||||||
|             else: |             else: | ||||||
|                 line_feed_sep = "\n" |                 line_feed_sep = "\n" | ||||||
|  |  | ||||||
|             snapshot_contents = '' |  | ||||||
|             with open(watch_history[dates[-1]], 'rb') as f: |             with open(watch_history[dates[-1]], 'rb') as f: | ||||||
|                 snapshot_contents = f.read() |                 snapshot_contents = f.read() | ||||||
|  |  | ||||||
| @@ -77,8 +78,10 @@ class update_worker(threading.Thread): | |||||||
|                 'diff': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], line_feed_sep=line_feed_sep), |                 'diff': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], line_feed_sep=line_feed_sep), | ||||||
|                 'diff_full': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], True, line_feed_sep=line_feed_sep) |                 'diff_full': diff.render_diff(watch_history[dates[-2]], watch_history[dates[-1]], True, line_feed_sep=line_feed_sep) | ||||||
|             }) |             }) | ||||||
|  |             logging.info (">> SENDING NOTIFICATION") | ||||||
|             self.notification_q.put(n_object) |             self.notification_q.put(n_object) | ||||||
|  |         else: | ||||||
|  |             logging.info (">> NO Notification sent, notification_url was empty in both watch and system") | ||||||
|  |  | ||||||
|     def send_filter_failure_notification(self, watch_uuid): |     def send_filter_failure_notification(self, watch_uuid): | ||||||
|  |  | ||||||
| @@ -183,6 +186,9 @@ class update_worker(threading.Thread): | |||||||
|                         process_changedetection_results = False |                         process_changedetection_results = False | ||||||
|  |  | ||||||
|                     except FilterNotFoundInResponse as e: |                     except FilterNotFoundInResponse as e: | ||||||
|  |                         if not self.datastore.data['watching'].get(uuid): | ||||||
|  |                             continue | ||||||
|  |  | ||||||
|                         err_text = "Warning, filter '{}' not found".format(str(e)) |                         err_text = "Warning, filter '{}' not found".format(str(e)) | ||||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, |                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||||
|                                                                            # So that we get a trigger when the content is added again |                                                                            # So that we get a trigger when the content is added again | ||||||
|   | |||||||
| @@ -6,6 +6,8 @@ services: | |||||||
|       hostname: changedetection |       hostname: changedetection | ||||||
|       volumes: |       volumes: | ||||||
|         - changedetection-data:/datastore |         - changedetection-data:/datastore | ||||||
|  | # Configurable proxy list support, see https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#proxy-list-support | ||||||
|  | #        - ./proxies.json:/datastore/proxies.json | ||||||
|  |  | ||||||
|   #    environment: |   #    environment: | ||||||
|   #        Default listening port, can also be changed with the -p option |   #        Default listening port, can also be changed with the -p option | ||||||
| @@ -30,7 +32,7 @@ services: | |||||||
|   # |   # | ||||||
|   #             https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch-option-proxy |   #             https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch-option-proxy | ||||||
|   # |   # | ||||||
|   #        Plain requsts - proxy support example. |   #        Plain requests - proxy support example. | ||||||
|   #      - HTTP_PROXY=socks5h://10.10.1.10:1080 |   #      - HTTP_PROXY=socks5h://10.10.1.10:1080 | ||||||
|   #      - HTTPS_PROXY=socks5h://10.10.1.10:1080 |   #      - HTTPS_PROXY=socks5h://10.10.1.10:1080 | ||||||
|   # |   # | ||||||
| @@ -43,6 +45,9 @@ services: | |||||||
|   #        Respect proxy_pass type settings, `proxy_set_header Host "localhost";` and `proxy_set_header X-Forwarded-Prefix /app;` |   #        Respect proxy_pass type settings, `proxy_set_header Host "localhost";` and `proxy_set_header X-Forwarded-Prefix /app;` | ||||||
|   #        More here https://github.com/dgtlmoon/changedetection.io/wiki/Running-changedetection.io-behind-a-reverse-proxy-sub-directory |   #        More here https://github.com/dgtlmoon/changedetection.io/wiki/Running-changedetection.io-behind-a-reverse-proxy-sub-directory | ||||||
|   #      - USE_X_SETTINGS=1 |   #      - USE_X_SETTINGS=1 | ||||||
|  |   # | ||||||
|  |   #        Hides the `Referer` header so that monitored websites can't see the changedetection.io hostname. | ||||||
|  |   #      - HIDE_REFERER=true | ||||||
|  |  | ||||||
|       # Comment out ports: when using behind a reverse proxy , enable networks: etc. |       # Comment out ports: when using behind a reverse proxy , enable networks: etc. | ||||||
|       ports: |       ports: | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								docs/proxy-example.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/proxy-example.jpg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 46 KiB | 
| @@ -1,8 +1,8 @@ | |||||||
| flask~= 2.0 | flask ~= 2.0 | ||||||
| flask_wtf | flask_wtf | ||||||
| eventlet>=0.31.0 | eventlet >= 0.31.0 | ||||||
| validators | validators | ||||||
| timeago ~=1.0 | timeago ~= 1.0 | ||||||
| inscriptis ~= 2.2 | inscriptis ~= 2.2 | ||||||
| feedgen ~= 0.9 | feedgen ~= 0.9 | ||||||
| flask-login ~= 0.5 | flask-login ~= 0.5 | ||||||
| @@ -10,15 +10,20 @@ flask_restful | |||||||
| pytz | pytz | ||||||
|  |  | ||||||
| # Set these versions together to avoid a RequestsDependencyWarning | # Set these versions together to avoid a RequestsDependencyWarning | ||||||
| requests[socks] ~= 2.26 | # >= 2.26 also adds Brotli support if brotli is installed | ||||||
|  | brotli ~= 1.0 | ||||||
|  | requests[socks] ~= 2.28 | ||||||
|  |  | ||||||
| urllib3 > 1.26 | urllib3 > 1.26 | ||||||
| chardet > 2.3.0 | chardet > 2.3.0 | ||||||
|  |  | ||||||
| wtforms ~= 3.0 | wtforms ~= 3.0 | ||||||
| jsonpath-ng ~= 1.5.3 | jsonpath-ng ~= 1.5.3 | ||||||
|  |  | ||||||
|  | # jq not available on Windows so must be installed manually | ||||||
|  |  | ||||||
| # Notification library | # Notification library | ||||||
| apprise ~= 1.0.0 | apprise ~= 1.1.0 | ||||||
|  |  | ||||||
| # apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 | # apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 | ||||||
| paho-mqtt | paho-mqtt | ||||||
| @@ -41,4 +46,9 @@ selenium ~= 4.1.0 | |||||||
| # need to revisit flask login versions | # need to revisit flask login versions | ||||||
| werkzeug ~= 2.0.0 | werkzeug ~= 2.0.0 | ||||||
|  |  | ||||||
|  | # Templating, so far just in the URLs but in the future can be for the notifications also | ||||||
|  | jinja2 ~= 3.1 | ||||||
|  | jinja2-time | ||||||
|  |  | ||||||
| # playwright is installed at Dockerfile build time because it's not available on all platforms | # playwright is installed at Dockerfile build time because it's not available on all platforms | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user