mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 14:47:21 +00:00 
			
		
		
		
	Compare commits
	
		
			67 Commits
		
	
	
		
			watch-queu
			...
			add-system
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | acb3fa0841 | ||
|   | 614431ff07 | ||
|   | 492bbce6b6 | ||
|   | 0394a56be5 | ||
|   | 7839551d6b | ||
|   | 9c5588c791 | ||
|   | 5a43a350de | ||
|   | 3c31f023ce | ||
|   | 4cbcc59461 | ||
|   | 4be0260381 | ||
|   | 957a3c1c16 | ||
|   | 85897e0bf9 | ||
|   | 63095f70ea | ||
|   | 8d5b0b5576 | ||
|   | 1b077abd93 | ||
|   | 32ea1a8721 | ||
|   | fff32cef0d | ||
|   | 8fb146f3e4 | ||
|   | 770b0faa45 | ||
|   | f6faa90340 | ||
|   | 669fd3ae0b | ||
|   | 17d37fb626 | ||
|   | dfa7fc3a81 | ||
|   | cd467df97a | ||
|   | 71bc2fed82 | ||
|   | 738fcfe01c | ||
|   | 3ebb2ab9ba | ||
|   | ac98bc9144 | ||
|   | 3705ce6681 | ||
|   | f7ea99412f | ||
|   | d4715e2bc8 | ||
|   | 8567a83c47 | ||
|   | 77fdf59ae3 | ||
|   | 0e194aa4b4 | ||
|   | 2ba55bb477 | ||
|   | 4c759490da | ||
|   | 58a52c1f60 | ||
|   | 22638399c1 | ||
|   | e3381776f2 | ||
|   | 26e2f21a80 | ||
|   | b6009ae9ff | ||
|   | b046d6ef32 | ||
|   | e154a3cb7a | ||
|   | 1262700263 | ||
|   | 434c5813b9 | ||
|   | 0a3dc7d77b | ||
|   | a7e296de65 | ||
|   | bd0fbaaf27 | ||
|   | 0c111bd9ae | ||
|   | ed9ac0b7fb | ||
|   | 743a3069bb | ||
|   | fefc39427b | ||
|   | 2c6faa7c4e | ||
|   | 6168cd2899 | ||
|   | f3c7c969d8 | ||
|   | 1355c2a245 | ||
|   | 96cf1a06df | ||
|   | 019a4a0375 | ||
|   | db2f7b80ea | ||
|   | bfabd7b094 | ||
|   | d92dbfe765 | ||
|   | 67d2441334 | ||
|   | 3c30bc02d5 | ||
|   | dcb54117d5 | ||
|   | b1e32275dc | ||
|   | e2a6865932 | ||
|   | f04adb7202 | 
							
								
								
									
										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** | ||||
| 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 | ||||
| on: [push, pull_request] | ||||
|  | ||||
| jobs: | ||||
|   test-build: | ||||
|   test-application: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|  | ||||
|       - uses: actions/checkout@v2 | ||||
|       - name: Set up Python 3.9 | ||||
|         uses: actions/setup-python@v2 | ||||
|         with: | ||||
|           python-version: 3.9 | ||||
|  | ||||
|       - name: Show env vars | ||||
|         run: set | ||||
|  | ||||
|       - name: Install dependencies | ||||
|         run: | | ||||
|           python -m pip install --upgrade pip | ||||
|           pip install flake8 pytest | ||||
|           if [ -f requirements.txt ]; then pip install -r requirements.txt; fi | ||||
|           if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi | ||||
|  | ||||
|       - name: Lint with flake8 | ||||
|         run: | | ||||
|           # 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 | ||||
|           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! | ||||
|  | ||||
| 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 | ||||
|   | ||||
							
								
								
									
										17
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -5,13 +5,14 @@ FROM python:3.8-slim as builder | ||||
| ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1 | ||||
|  | ||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
|     libssl-dev \ | ||||
|     libffi-dev \ | ||||
|     g++ \ | ||||
|     gcc \ | ||||
|     libc-dev \ | ||||
|     libffi-dev \ | ||||
|     libssl-dev \ | ||||
|     libxslt-dev \ | ||||
|     zlib1g-dev \ | ||||
|     g++ | ||||
|     make \ | ||||
|     zlib1g-dev | ||||
|  | ||||
| RUN mkdir /install | ||||
| WORKDIR /install | ||||
| @@ -22,9 +23,14 @@ RUN pip install --target=/dependencies -r /requirements.txt | ||||
|  | ||||
| # Playwright is an alternative to Selenium | ||||
| # Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing | ||||
| 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." | ||||
|  | ||||
|  | ||||
| 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 | ||||
| FROM python:3.8-slim | ||||
|  | ||||
| @@ -58,6 +64,7 @@ EXPOSE 5000 | ||||
|  | ||||
| # The actual flask app | ||||
| COPY changedetectionio /app/changedetectionio | ||||
|  | ||||
| # The eventlet server wrapper | ||||
| COPY changedetection.py /app/changedetection.py | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,7 @@ recursive-include changedetectionio/api * | ||||
| recursive-include changedetectionio/templates * | ||||
| recursive-include changedetectionio/static * | ||||
| recursive-include changedetectionio/model * | ||||
| recursive-include changedetectionio/tests * | ||||
| include changedetection.py | ||||
| global-exclude *.pyc | ||||
| global-exclude node_modules | ||||
|   | ||||
| @@ -1,45 +1,48 @@ | ||||
| #  changedetection.io | ||||
|  | ||||
| <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> | ||||
| ## Web Site Change Detection, Monitoring and Notification. | ||||
|  | ||||
| ## 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!_  | ||||
|  | ||||
| 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/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) | ||||
|  | ||||
|  | ||||
| <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"  /> | ||||
|  | ||||
|  | ||||
| **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.  | ||||
|  | ||||
| [**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)  | ||||
|  | ||||
|  | ||||
| #### Example use cases | ||||
|  | ||||
| Know when ... | ||||
|  | ||||
| - Government department updates (changes are often only on their websites) | ||||
| - Local government news (changes are often only on their websites) | ||||
| - Products and services have a change in pricing | ||||
| - _Out of stock notification_ and _Back In stock notification_ | ||||
| - Governmental department updates (changes are often only on their websites) | ||||
| - New software releases, security advisories when you're not on their mailing list. | ||||
| - Festivals with 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 | ||||
| - University/organisation news from their website | ||||
| - 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 | ||||
| $ 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`) | ||||
| @@ -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. | ||||
|  | ||||
| ### 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. | ||||
|  | ||||
|   | ||||
							
								
								
									
										56
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										56
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,9 +1,8 @@ | ||||
| ## 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) | ||||
|  | ||||
| @@ -11,13 +10,16 @@ | ||||
|  | ||||
| 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! | ||||
| - Javascript browser included | ||||
| - Unlimited checks and watches! | ||||
| <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 " /> | ||||
|  | ||||
|  | ||||
| #### 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 | ||||
| - 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 | ||||
|  | ||||
| ### 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/ | ||||
|  | ||||
| ### 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 | ||||
| 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`.) | ||||
|  | ||||
| ## Notifications | ||||
| @@ -142,7 +151,7 @@ Now you can also customise your notification content! | ||||
|  | ||||
| ## 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! | ||||
|  | ||||
| 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> | ||||
| @@ -162,7 +182,7 @@ When you enable a `json:` filter, you can even automatically extract and parse e | ||||
| </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 | ||||
|  | ||||
|   | ||||
| @@ -1,16 +1,5 @@ | ||||
| #!/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 os | ||||
| import queue | ||||
| @@ -44,7 +33,7 @@ from flask_wtf import CSRFProtect | ||||
| from changedetectionio import html_tools | ||||
| from changedetectionio.api import api_v1 | ||||
|  | ||||
| __version__ = '0.39.17.2' | ||||
| __version__ = '0.39.20.4' | ||||
|  | ||||
| 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>', | ||||
|                            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 | ||||
|  | ||||
|         # 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 | ||||
|         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 | ||||
|         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 | ||||
|         if all(value == 0 or value == None for value in datastore.data['watching'][uuid]['time_between_check'].values()): | ||||
|             default['time_between_check'] = deepcopy(datastore.data['settings']['requests']['time_between_check']) | ||||
|  | ||||
|         # Defaults for proxy choice | ||||
|         if datastore.proxy_list is not None:  # When enabled | ||||
|             # @todo | ||||
|             # 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): | ||||
|                 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 | ||||
|             del form.proxy | ||||
|         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(): | ||||
|             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']: | ||||
|                 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 | ||||
|             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 | ||||
|             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", | ||||
|                                      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'], | ||||
|                                      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_enabled=visualselector_enabled, | ||||
|                                      playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False) | ||||
|                                      watch=datastore.data['watching'][uuid], | ||||
|                                      ) | ||||
|  | ||||
|         return output | ||||
| @@ -672,26 +671,34 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         default = deepcopy(datastore.data['settings']) | ||||
|         if datastore.proxy_list is not None: | ||||
|             available_proxies = list(datastore.proxy_list.keys()) | ||||
|             # When enabled | ||||
|             system_proxy = datastore.data['settings']['requests']['proxy'] | ||||
|             # 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 | ||||
|  | ||||
|             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 | ||||
|             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 | ||||
|         form = forms.globalSettingsForm(formdata=request.form if request.method == 'POST' else None, | ||||
|                                         data=default | ||||
|                                         ) | ||||
|  | ||||
|         # Remove the last option 'System default' | ||||
|         form.application.form.notification_format.choices.pop() | ||||
|  | ||||
|         if datastore.proxy_list is None: | ||||
|             # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead | ||||
|             del form.requests.form.proxy | ||||
|         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': | ||||
|             # 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'], | ||||
|                                  hide_remove_pass=os.getenv("SALTED_PASS", False), | ||||
|                                  api_key=datastore.data['settings']['application'].get('api_access_token'), | ||||
|                                  emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False)) | ||||
|                                  emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||
|                                  settings_application=datastore.data['settings']['application']) | ||||
|  | ||||
|         return output | ||||
|  | ||||
| @@ -811,8 +819,10 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|         newest_file = history[dates[-1]] | ||||
|  | ||||
|         # Read as binary and force decode as UTF-8 | ||||
|         # Windows may fail decode in python if we just use 'r' mode (chardet decode exception) | ||||
|         try: | ||||
|             with open(newest_file, 'r') as f: | ||||
|             with open(newest_file, 'r', encoding='utf-8', errors='ignore') as f: | ||||
|                 newest_version_file_contents = f.read() | ||||
|         except Exception as e: | ||||
|             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]] | ||||
|  | ||||
|         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() | ||||
|         except Exception as e: | ||||
|             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] | ||||
|         filename = watch.history[timestamp] | ||||
|         try: | ||||
|             with open(filename, 'r') as f: | ||||
|             with open(filename, 'r', encoding='utf-8', errors='ignore') as f: | ||||
|                 tmp = f.readlines() | ||||
|  | ||||
|                 # Get what needs to be highlighted | ||||
| @@ -1186,6 +1196,63 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|         flash("{} watches are queued for rechecking.".format(i)) | ||||
|         return redirect(url_for('index', tag=tag)) | ||||
|  | ||||
|     @app.route("/form/checkbox-operations", methods=['POST']) | ||||
|     @login_required | ||||
|     def form_watch_list_checkbox_operations(): | ||||
|         op = request.form['op'] | ||||
|         uuids = request.form.getlist('uuids') | ||||
|  | ||||
|         if (op == 'delete'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.delete(uuid.strip()) | ||||
|             flash("{} watches deleted".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'pause'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = True | ||||
|  | ||||
|             flash("{} watches paused".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'unpause'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = False | ||||
|             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')) | ||||
|  | ||||
|     @app.route("/api/share-url", methods=['GET']) | ||||
|     @login_required | ||||
|     def form_share_put_watch(): | ||||
| @@ -1321,6 +1388,8 @@ def ticker_thread_check_time_launch_checks(): | ||||
|     import random | ||||
|     from changedetectionio import update_worker | ||||
|  | ||||
|     proxy_last_called_time = {} | ||||
|  | ||||
|     recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 20)) | ||||
|     print("System env MINIMUM_SECONDS_RECHECK_TIME", recheck_time_minimum_seconds) | ||||
|  | ||||
| @@ -1381,17 +1450,42 @@ def ticker_thread_check_time_launch_checks(): | ||||
|                 if watch.jitter_seconds == 0: | ||||
|                     watch.jitter_seconds = random.uniform(-abs(jitter), jitter) | ||||
|  | ||||
|  | ||||
|             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 not uuid in running_uuids and uuid not in [q_uuid for p,q_uuid in update_q.queue]: | ||||
|                     print("> Queued watch UUID {} last checked at {} queued at {:0.2f} jitter {:0.2f}s, {:0.2f}s since last checked".format(uuid, | ||||
|                                                                                                          watch['last_checked'], | ||||
|                                                                                                          now, | ||||
|                                                                                                          watch.jitter_seconds, | ||||
|                                                                                                          now - watch['last_checked'])) | ||||
|  | ||||
|                     # 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. | ||||
|                     priority = int(time.time()) | ||||
|                     print( | ||||
|                         "> Queued watch UUID {} last checked at {} queued at {:0.2f} priority {} jitter {:0.2f}s, {:0.2f}s since last checked".format( | ||||
|                             uuid, | ||||
|                             watch['last_checked'], | ||||
|                             now, | ||||
|                             priority, | ||||
|                             watch.jitter_seconds, | ||||
|                             now - watch['last_checked'])) | ||||
|                     # Into the queue with you | ||||
|                     update_q.put((5, uuid)) | ||||
|                     update_q.put((priority, uuid)) | ||||
|  | ||||
|                     # Reset for next time | ||||
|                     watch.jitter_seconds = 0 | ||||
|   | ||||
| @@ -122,3 +122,33 @@ class CreateWatch(Resource): | ||||
|             return {'status': "OK"}, 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: | ||||
|                 t = self.datastore.threshold_seconds | ||||
|             time_since_check = time.time() - watch.get('last_checked') | ||||
|             if time_since_check > 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 | ||||
|                     ) | ||||
|  | ||||
|     # 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 | ||||
|     # Set environment var USE_X_SETTINGS=1 on this script | ||||
|     # And then in your proxy_pass settings | ||||
|   | ||||
| @@ -316,6 +316,7 @@ class base_html_playwright(Fetcher): | ||||
|         import playwright._impl._api_types | ||||
|         from playwright._impl._api_types import Error, TimeoutError | ||||
|         response = None | ||||
|  | ||||
|         with sync_playwright() as p: | ||||
|             browser_type = getattr(p, self.browser_type) | ||||
|  | ||||
| @@ -373,8 +374,11 @@ class base_html_playwright(Fetcher): | ||||
|                 print("response object was 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 | ||||
|             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) | ||||
|  | ||||
|                 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.status_code = response.status | ||||
|             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 | ||||
|             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? | ||||
|         self.status_code = 200 | ||||
|         # @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.headers = {} | ||||
|  | ||||
|         self.screenshot = self.driver.get_screenshot_as_png() | ||||
|  | ||||
|     # Does the connection to the webdriver work? run a test connection. | ||||
|     def is_ready(self): | ||||
|         from selenium import webdriver | ||||
| @@ -564,6 +575,11 @@ class html_requests(Fetcher): | ||||
|             ignore_status_codes=False, | ||||
|             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 = {} | ||||
|  | ||||
|         # Allows override the proxy on a per-request basis | ||||
|   | ||||
| @@ -20,34 +20,6 @@ class perform_site_check(): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         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 | ||||
|     # So convert it to inline flag "foobar(?i)" type configuration | ||||
|     def forward_slash_enclosed_regex_to_options(self, regex): | ||||
| @@ -63,13 +35,13 @@ class perform_site_check(): | ||||
|  | ||||
|  | ||||
|     def run(self, uuid): | ||||
|         timestamp = int(time.time())  # used for storage etc too | ||||
|  | ||||
|         changed_detected = False | ||||
|         screenshot = False  # as bytes | ||||
|         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 | ||||
|         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 | ||||
|         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 | ||||
|         request_headers = self.datastore.data['settings']['headers'].copy() | ||||
| @@ -92,10 +64,10 @@ class perform_site_check(): | ||||
|         if 'Accept-Encoding' in request_headers and "br" in request_headers['Accept-Encoding']: | ||||
|             request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '') | ||||
|  | ||||
|         timeout = self.datastore.data['settings']['requests']['timeout'] | ||||
|         url = self.datastore.get_val(uuid, 'url') | ||||
|         request_body = self.datastore.get_val(uuid, 'body') | ||||
|         request_method = self.datastore.get_val(uuid, 'method') | ||||
|         timeout = self.datastore.data['settings']['requests'].get('timeout') | ||||
|         url = watch.get('url') | ||||
|         request_body = self.datastore.data['watching'][uuid].get('body') | ||||
|         request_method = self.datastore.data['watching'][uuid].get('method') | ||||
|         ignore_status_codes = self.datastore.data['watching'][uuid].get('ignore_status_codes', False) | ||||
|  | ||||
|         # source: support | ||||
| @@ -112,9 +84,13 @@ class perform_site_check(): | ||||
|             # If the klass doesnt exist, just use a default | ||||
|             klass = getattr(content_fetcher, "html_requests") | ||||
|  | ||||
|         proxy_id = self.datastore.get_preferred_proxy_for_watch(uuid=uuid) | ||||
|         proxy_url = None | ||||
|         if proxy_id: | ||||
|             proxy_url = self.datastore.proxy_list.get(proxy_id).get('url') | ||||
|             print ("UUID {} Using proxy {}".format(uuid, proxy_url)) | ||||
|  | ||||
|         proxy_args = self.set_proxy_from_list(watch) | ||||
|         fetcher = klass(proxy_override=proxy_args) | ||||
|         fetcher = klass(proxy_override=proxy_url) | ||||
|  | ||||
|         # Configurable per-watch or global extra delay before extracting text (for webDriver types) | ||||
|         system_webdriver_delay = self.datastore.data['settings']['application'].get('webdriver_delay', None) | ||||
| @@ -165,8 +141,9 @@ class perform_site_check(): | ||||
|             has_filter_rule = True | ||||
|  | ||||
|         if has_filter_rule: | ||||
|             if 'json:' in css_filter_rule: | ||||
|                 stripped_text_from_html = html_tools.extract_json_as_string(content=fetcher.content, jsonpath_filter=css_filter_rule) | ||||
|             json_filter_prefixes = ['json:', 'jq:'] | ||||
|             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 | ||||
|  | ||||
|         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 | ||||
|                 # 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): | ||||
| @@ -314,14 +333,14 @@ class quickWatchForm(Form): | ||||
|  | ||||
| # Common to a single watch and the global settings | ||||
| class commonSettingsForm(Form): | ||||
|  | ||||
|     notification_urls = StringListField('Notification URL list', validators=[validators.Optional(), ValidateNotificationBodyAndTitleWhenURLisSet(), ValidateAppRiseServers()]) | ||||
|     notification_title = StringField('Notification title', default=default_notification_title, 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(), default=default_notification_format) | ||||
|     notification_urls = StringListField('Notification URL list', validators=[validators.Optional(), ValidateAppRiseServers()]) | ||||
|     notification_title = StringField('Notification title', validators=[validators.Optional(), ValidateTokensList()]) | ||||
|     notification_body = TextAreaField('Notification body', validators=[validators.Optional(), ValidateTokensList()]) | ||||
|     notification_format = SelectField('Notification format', choices=valid_notification_formats.keys()) | ||||
|     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) | ||||
|     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): | ||||
|  | ||||
| @@ -355,6 +374,8 @@ class watchForm(commonSettingsForm): | ||||
|     filter_failure_notification_send = BooleanField( | ||||
|         'Send a notification when the filter can no longer be found on the page', default=False) | ||||
|  | ||||
|     notification_muted = BooleanField('Notifications Muted / Off', default=False) | ||||
|  | ||||
|     def validate(self, **kwargs): | ||||
|         if not super().validate(): | ||||
|             return False | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import json | ||||
| from typing import List | ||||
|  | ||||
| from bs4 import BeautifulSoup | ||||
| from jsonpath_ng.ext import parse | ||||
| import re | ||||
| from inscriptis import get_text | ||||
| from inscriptis.model.config import ParserConfig | ||||
| from jsonpath_ng.ext import parse | ||||
| from typing import List | ||||
| import json | ||||
| import re | ||||
|  | ||||
| class FilterNotFoundInResponse(ValueError): | ||||
|     def __init__(self, msg): | ||||
| @@ -79,19 +79,35 @@ def extract_element(find='title', html_content=''): | ||||
|     return element_text | ||||
|  | ||||
| # | ||||
| def _parse_json(json_data, jsonpath_filter): | ||||
|     s=[] | ||||
|     jsonpath_expression = parse(jsonpath_filter.replace('json:', '')) | ||||
|     match = jsonpath_expression.find(json_data) | ||||
| def _parse_json(json_data, json_filter): | ||||
|     if 'json:' in json_filter: | ||||
|         jsonpath_expression = parse(json_filter.replace('json:', '')) | ||||
|         match = jsonpath_expression.find(json_data) | ||||
|         return _get_stripped_text_from_json_match(match) | ||||
|  | ||||
|     if 'jq:' in json_filter: | ||||
|  | ||||
|         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. | ||||
|     if len(match) > 1: | ||||
|         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. | ||||
|     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.. | ||||
|     if not match: | ||||
| @@ -103,16 +119,16 @@ def _parse_json(json_data, jsonpath_filter): | ||||
|  | ||||
|     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 | ||||
|  | ||||
|     # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded <script type=ldjson> | ||||
|     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: | ||||
|  | ||||
|         # 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 = [] | ||||
|         soup = BeautifulSoup(content, 'html.parser') | ||||
|         bs_result = soup.findAll('script') | ||||
| @@ -131,7 +147,7 @@ def extract_json_as_string(content, jsonpath_filter): | ||||
|                 # Just skip it | ||||
|                 continue | ||||
|             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: | ||||
|                     break | ||||
|  | ||||
|   | ||||
| @@ -13,10 +13,6 @@ class model(dict): | ||||
|             'watching': {}, | ||||
|             'settings': { | ||||
|                 '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': { | ||||
|                     '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} | ||||
|  | ||||
| from changedetectionio.notification import ( | ||||
|     default_notification_body, | ||||
|     default_notification_format, | ||||
|     default_notification_title, | ||||
|     default_notification_format_for_watch | ||||
| ) | ||||
|  | ||||
|  | ||||
| @@ -32,9 +30,9 @@ class model(dict): | ||||
|             'ignore_text': [],  # List of text to ignore when calculating the comparison checksum | ||||
|             # Custom notification content | ||||
|             'notification_urls': [],  # List of URLs to add to the notification Queue (Usually AppRise) | ||||
|             'notification_title': default_notification_title, | ||||
|             'notification_body': default_notification_body, | ||||
|             'notification_format': default_notification_format, | ||||
|             'notification_title': None, | ||||
|             'notification_body': None, | ||||
|             'notification_format': default_notification_format_for_watch, | ||||
|             'notification_muted': False, | ||||
|             'css_filter': '', | ||||
|             'last_error': False, | ||||
| @@ -120,7 +118,10 @@ class model(dict): | ||||
|         if os.path.isfile(fname): | ||||
|             logging.debug("Reading history index " + str(time.time())) | ||||
|             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): | ||||
|             self.__newest_history_key = list(tmp_history.keys())[-1] | ||||
| @@ -153,28 +154,30 @@ class model(dict): | ||||
|         import uuid | ||||
|         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() | ||||
|         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)) | ||||
|  | ||||
|         # 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: | ||||
|             f.write(contents) | ||||
|             f.close() | ||||
|  | ||||
|         # Append to index | ||||
|         # @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: | ||||
|             f.write("{},{}\n".format(timestamp, snapshot_fname)) | ||||
|             f.close() | ||||
|  | ||||
|         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 | ||||
|  | ||||
|     @property | ||||
|   | ||||
| @@ -14,16 +14,19 @@ valid_tokens = { | ||||
|     '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 = { | ||||
|     'Text': NotifyFormat.TEXT, | ||||
|     'Markdown': NotifyFormat.MARKDOWN, | ||||
|     '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): | ||||
|  | ||||
|     # Get the notification body from datastore | ||||
|   | ||||
| @@ -9,6 +9,8 @@ | ||||
| # exit when any command fails | ||||
| set -e | ||||
|  | ||||
| SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) | ||||
|  | ||||
| find tests/test_*py -type f|while read test_name | ||||
| do | ||||
|   echo "TEST RUNNING $test_name" | ||||
| @@ -23,6 +25,13 @@ export BASE_URL="https://really-unique-domain.io" | ||||
| 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 | ||||
| # 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..." | ||||
| # 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 | ||||
| # takes a while to spin up | ||||
| sleep 5 | ||||
| @@ -48,4 +59,48 @@ pytest tests/test_errorhandling.py | ||||
| pytest tests/visualselector/test_fetch_data.py | ||||
|  | ||||
| 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 | 
| @@ -22,5 +22,18 @@ $(function () { | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|     // checkboxes - check all | ||||
|     $("#check-all").click(function (e) { | ||||
|         $('input[type=checkbox]').not(this).prop('checked', this.checked); | ||||
|     }); | ||||
|     // checkboxes - show/hide buttons | ||||
|     $("input[type=checkbox]").click(function (e) { | ||||
|         if ($('input[type=checkbox]:checked').length) { | ||||
|             $('#checkbox-operations').slideDown(); | ||||
|         } else { | ||||
|             $('#checkbox-operations').slideUp(); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -30,4 +30,11 @@ $(document).ready(function() { | ||||
|     }); | ||||
|     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(); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -555,3 +555,26 @@ ul { | ||||
|   .snapshot-age.error { | ||||
|     background-color: #ff0000; | ||||
|     color: #fff; } | ||||
|  | ||||
| #checkbox-operations { | ||||
|   background: rgba(0, 0, 0, 0.05); | ||||
|   padding: 1em; | ||||
|   border-radius: 10px; | ||||
|   margin-bottom: 1em; | ||||
|   display: none; } | ||||
|  | ||||
| .checkbox-uuid > * { | ||||
|   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; } | ||||
|   | ||||
| @@ -774,3 +774,33 @@ ul { | ||||
|   } | ||||
| } | ||||
|  | ||||
| #checkbox-operations { | ||||
|   background: rgba(0, 0, 0, 0.05); | ||||
|   padding: 1em; | ||||
|   border-radius: 10px; | ||||
|   margin-bottom: 1em; | ||||
|   display: none; | ||||
| } | ||||
| .checkbox-uuid { | ||||
|   > * { | ||||
|     vertical-align: middle; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .inline-warning { | ||||
|   > span { | ||||
|     display: inline-block; | ||||
|     vertical-align: middle; | ||||
|   } | ||||
|  | ||||
|   img.inline-warning-icon { | ||||
|     display: inline; | ||||
|     height: 26px; | ||||
|     vertical-align: middle; | ||||
|   } | ||||
|  | ||||
|   border: 1px solid #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"): | ||||
|         # Should only be active for docker | ||||
|         # logging.basicConfig(filename='/dev/stdout', level=logging.INFO) | ||||
|         self.needs_write = False | ||||
|         self.__data = App.model() | ||||
|         self.datastore_path = datastore_path | ||||
|         self.json_store_path = "{}/url-watches.json".format(self.datastore_path) | ||||
|         self.needs_write = False | ||||
|         self.proxy_list = None | ||||
|         self.start_time = time.time() | ||||
|         self.stop_thread = False | ||||
|  | ||||
|         self.__data = App.model() | ||||
|  | ||||
|         # Base definition for all watchers | ||||
|         # deepcopy part of #569 - not sure why its needed exactly | ||||
|         self.generic_definition = deepcopy(Watch.model(datastore_path = datastore_path, default={})) | ||||
| @@ -81,8 +81,6 @@ class ChangeDetectionStore: | ||||
|         except (FileNotFoundError, json.decoder.JSONDecodeError): | ||||
|             if include_default_watches: | ||||
|                 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://changedetection.io/CHANGELOG.txt', tag='changedetection.io') | ||||
|  | ||||
| @@ -113,9 +111,7 @@ class ChangeDetectionStore: | ||||
|             self.__data['settings']['application']['api_access_token'] = secret | ||||
|  | ||||
|         # Proxy list support - available as a selection in settings when text file is imported | ||||
|         # CSV list | ||||
|         # "name, address", or just "name" | ||||
|         proxy_list_file = "{}/proxies.txt".format(self.datastore_path) | ||||
|         proxy_list_file = "{}/proxies.json".format(self.datastore_path) | ||||
|         if path.isfile(proxy_list_file): | ||||
|             self.import_proxy_list(proxy_list_file) | ||||
|  | ||||
| @@ -244,10 +240,6 @@ class ChangeDetectionStore: | ||||
|  | ||||
|         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) | ||||
|     def clear_watch_history(self, uuid): | ||||
|         import pathlib | ||||
| @@ -341,6 +333,8 @@ class ChangeDetectionStore: | ||||
|  | ||||
|     # Save as PNG, PNG is larger but better for doing visual diff in the future | ||||
|     def save_screenshot(self, watch_uuid, screenshot: bytes, as_error=False): | ||||
|         if not self.data['watching'].get(watch_uuid): | ||||
|             return | ||||
|  | ||||
|         if as_error: | ||||
|             target_path = os.path.join(self.datastore_path, watch_uuid, "last-error-screenshot.png") | ||||
| @@ -354,14 +348,16 @@ class ChangeDetectionStore: | ||||
|             f.close() | ||||
|  | ||||
|     def save_error_text(self, watch_uuid, contents): | ||||
|  | ||||
|         if not self.data['watching'].get(watch_uuid): | ||||
|             return | ||||
|         target_path = os.path.join(self.datastore_path, watch_uuid, "last-error.txt") | ||||
|  | ||||
|         with open(target_path, 'w') as f: | ||||
|             f.write(contents) | ||||
|  | ||||
|     def save_xpath_data(self, watch_uuid, data, as_error=False): | ||||
|  | ||||
|         if not self.data['watching'].get(watch_uuid): | ||||
|             return | ||||
|         if as_error: | ||||
|             target_path = os.path.join(self.datastore_path, watch_uuid, "elements-error.json") | ||||
|         else: | ||||
| @@ -437,20 +433,42 @@ class ChangeDetectionStore: | ||||
|                     unlink(item) | ||||
|  | ||||
|     def import_proxy_list(self, filename): | ||||
|         import csv | ||||
|         with open(filename, newline='') as f: | ||||
|             reader = csv.reader(f, skipinitialspace=True) | ||||
|             # @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 | ||||
|         with open(filename) as f: | ||||
|             self.proxy_list = json.load(f) | ||||
|             print ("Registered proxy list", list(self.proxy_list.keys())) | ||||
|  | ||||
|  | ||||
|     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 | ||||
|     # 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 | ||||
| @@ -535,4 +553,33 @@ class ChangeDetectionStore: | ||||
|                 del(watch['last_changed']) | ||||
|             except: | ||||
|                 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 %} | ||||
|  | ||||
| {% macro render_common_settings_form(form, current_base_url, emailprefix) %} | ||||
| {% macro render_common_settings_form(form, emailprefix, settings_application) %} | ||||
|                         <div class="pure-control-group"> | ||||
|                             {{ render_field(form.notification_urls, rows=5, placeholder="Examples: | ||||
|     Gitter - gitter://token/room | ||||
|     Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail | ||||
|     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"> | ||||
|                               <ul> | ||||
| @@ -26,15 +27,16 @@ | ||||
|                         </div> | ||||
|                         <div id="notification-customisation" 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> | ||||
|                             </div> | ||||
|                             <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> | ||||
|                             </div> | ||||
|                             <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> | ||||
|                             </div> | ||||
|                             <div class="pure-controls"> | ||||
| @@ -94,7 +96,7 @@ | ||||
|                                 </table> | ||||
|                                 <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> | ||||
|                             </div> | ||||
|                         </div> | ||||
|   | ||||
| @@ -77,6 +77,7 @@ | ||||
|                         <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>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> | ||||
|                     </div> | ||||
|                 {% if form.proxy %} | ||||
| @@ -135,10 +136,20 @@ User-Agent: wonderbra 1.0") }} | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="notifications"> | ||||
|                 <strong>Note: <i>These settings override the global settings for this watch.</i></strong> | ||||
|                 <fieldset> | ||||
|                     <div class="field-group"> | ||||
|                         {{ render_common_settings_form(form, current_base_url, emailprefix) }} | ||||
|                     <div  class="pure-control-group inline-radio"> | ||||
|                       {{ 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> | ||||
|                 </fieldset> | ||||
|             </div> | ||||
| @@ -173,8 +184,16 @@ User-Agent: wonderbra 1.0") }} | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                     <ul> | ||||
|                         <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 | ||||
|                                 href="https://jsonpath.com/" target="new">test your JSONPath here</a></li> | ||||
|                         <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). | ||||
|                             <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, | ||||
|                             <ul> | ||||
|                                 <li>Example:  <code>//*[contains(@class, 'sametext')]</code> or <code>xpath://*[contains(@class, 'sametext')]</code>, <a | ||||
| @@ -183,7 +202,7 @@ User-Agent: wonderbra 1.0") }} | ||||
|                             </ul> | ||||
|                             </li> | ||||
|                     </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/> | ||||
|                 </span> | ||||
|                     </div> | ||||
|   | ||||
| @@ -57,6 +57,7 @@ | ||||
|          </br> | ||||
|          {% if is_html_webdriver %} | ||||
|            {% if screenshot %} | ||||
|              <div class="snapshot-age">{{watch.snapshot_screenshot_ctime|format_timestamp_timeago}}</div> | ||||
|              <img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request"/> | ||||
|            {% else %} | ||||
|               No screenshot available just yet! Try rechecking the page. | ||||
|   | ||||
| @@ -60,7 +60,7 @@ | ||||
|                         {{ render_field(form.application.form.base_url, placeholder="http://yoursite.com:5000/", | ||||
|                         class="m-d") }} | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                             Base URL used for the {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>. | ||||
|                         </span> | ||||
|                     </div> | ||||
| @@ -87,7 +87,7 @@ | ||||
|             <div class="tab-pane-inner" id="notifications"> | ||||
|                 <fieldset> | ||||
|                     <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> | ||||
|                 </fieldset> | ||||
|             </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>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> | ||||
|                     </span> | ||||
|                     <br/> | ||||
|                     Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a> | ||||
|                 </div> | ||||
|                 <fieldset class="pure-group" id="webdriver-override-options"> | ||||
|                     <div class="pure-form-message-inline"> | ||||
|   | ||||
| @@ -24,6 +24,17 @@ | ||||
|         </fieldset> | ||||
|         <span style="color:#eee; font-size: 80%;"><img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" /> Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></a></span> | ||||
|     </form> | ||||
|  | ||||
|     <form class="pure-form" action="{{ url_for('form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|     <div id="checkbox-operations"> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="pause">Pause</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="unpause">UnPause</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="mute">Mute</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%"  name="op" value="unmute">UnMute</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="font-size: 70%" name="op" value="notification-default">Use default notification</button> | ||||
|         <button class="pure-button button-secondary button-xsmall" style="background: #dd4242; font-size: 70%" name="op" value="delete">Delete</button> | ||||
|     </div> | ||||
|     <div> | ||||
|         <a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag }}">All</a> | ||||
|         {% for tag in tags %} | ||||
| @@ -41,7 +52,7 @@ | ||||
|         <table class="pure-table pure-table-striped watch-table"> | ||||
|             <thead> | ||||
|             <tr> | ||||
|                 <th>#</th> | ||||
|                 <th><input style="vertical-align: middle" type="checkbox" id="check-all"/> #</th> | ||||
|                 <th></th> | ||||
|                 {% set link_order = "desc" if sort_order else "asc" %} | ||||
|                 {% set arrow_span = "" %} | ||||
| @@ -66,9 +77,13 @@ | ||||
|                 {% if watch.paused is defined and watch.paused != False %}paused{% endif %} | ||||
|                 {% if watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}unviewed{% endif %} | ||||
|                 {% if watch.uuid in queued_uuids %}queued{% endif %}"> | ||||
|                 <td class="inline">{{ loop.index }}</td> | ||||
|                 <td class="inline checkbox-uuid" ><input name="uuids"  type="checkbox" value="{{ watch.uuid}} "/> <span>{{ loop.index }}</span></td> | ||||
|                 <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> | ||||
|                 </td> | ||||
|                 <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} | ||||
| @@ -129,5 +144,6 @@ | ||||
|          #} | ||||
|  | ||||
|     </div> | ||||
|     </form> | ||||
| </div> | ||||
| {% endblock %} | ||||
|   | ||||
							
								
								
									
										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? | ||||
|     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 | ||||
|     res = client.delete( | ||||
|         url_for("watch", uuid=watch_uuid), | ||||
|   | ||||
| @@ -2,10 +2,15 @@ | ||||
| # coding=utf-8 | ||||
| 
 | ||||
| import time | ||||
| from flask import url_for | ||||
| from flask import url_for, escape | ||||
| from . util import live_server_setup | ||||
| import pytest | ||||
| jq_support = True | ||||
| 
 | ||||
| try: | ||||
|     import jq | ||||
| except ModuleNotFoundError: | ||||
|     jq_support = False | ||||
| 
 | ||||
| def test_setup(live_server): | ||||
|     live_server_setup(live_server) | ||||
| @@ -36,16 +41,28 @@ and it can also be repeated | ||||
|     from .. import html_tools | ||||
| 
 | ||||
|     # 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" | ||||
| 
 | ||||
|     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" | ||||
| 
 | ||||
|     # When nothing at all is found, it should throw JSONNOTFound | ||||
|     # Which is caught and shown to the user in the watch-overview table | ||||
|     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(): | ||||
|     data = """ | ||||
| @@ -66,6 +83,7 @@ def set_original_ext_response(): | ||||
| 
 | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(data) | ||||
|     return None | ||||
| 
 | ||||
| def set_modified_ext_response(): | ||||
|     data = """ | ||||
| @@ -86,6 +104,7 @@ def set_modified_ext_response(): | ||||
| 
 | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(data) | ||||
|     return None | ||||
| 
 | ||||
| def set_original_response(): | ||||
|     test_return_data = """ | ||||
| @@ -184,10 +203,10 @@ def test_check_json_without_filter(client, live_server): | ||||
|     assert b'"<b>' in res.data | ||||
|     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): | ||||
|     json_filter = 'json:boss.name' | ||||
| 
 | ||||
| def check_json_filter(json_filter, client, live_server): | ||||
|     set_original_response() | ||||
| 
 | ||||
|     # Give the endpoint time to spin up | ||||
| @@ -226,7 +245,7 @@ def test_check_json_filter(client, live_server): | ||||
|     res = client.get( | ||||
|         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 | ||||
|     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 | ||||
|     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): | ||||
|     json_filter = "json:$['available']" | ||||
| def test_check_jsonpath_filter(client, live_server): | ||||
|     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() | ||||
| 
 | ||||
|     # 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 | ||||
|     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 | ||||
| # Stuff to consider here | ||||
| # - 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 | ||||
| # - Form should let us enter a selector that doesnt (yet) match anything | ||||
| def test_check_json_ext_filter(client, live_server): | ||||
|     json_filter = 'json:$[?(@.status==Sold)]' | ||||
| 
 | ||||
| def check_json_ext_filter(json_filter, client, live_server): | ||||
|     set_original_ext_response() | ||||
| 
 | ||||
|     # Give the endpoint time to spin up | ||||
| @@ -350,7 +384,7 @@ def test_check_json_ext_filter(client, live_server): | ||||
|     res = client.get( | ||||
|         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 | ||||
|     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'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 . util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup | ||||
| 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): | ||||
|     live_server_setup(live_server) | ||||
| @@ -20,9 +26,26 @@ def test_check_notification(client, live_server): | ||||
|  | ||||
|     # Re 360 - new install should have defaults set | ||||
|     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_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 | ||||
|     env_base_url = os.getenv('BASE_URL', '').strip() | ||||
|     if len(env_base_url): | ||||
| @@ -47,8 +70,6 @@ def test_check_notification(client, live_server): | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # 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) | ||||
|  | ||||
| @@ -158,6 +179,30 @@ def test_check_notification(client, live_server): | ||||
|     # be sure we see it in the output log | ||||
|     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 | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
| @@ -180,20 +225,20 @@ def test_notification_validation(client, live_server): | ||||
|     assert b"Watch added" in res.data | ||||
|  | ||||
|     # Re #360 some validation | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"notification_urls": 'json://localhost/foobar', | ||||
|               "notification_title": "", | ||||
|               "notification_body": "", | ||||
|               "notification_format": "Text", | ||||
|               "url": test_url, | ||||
|               "tag": "my tag", | ||||
|               "title": "my title", | ||||
|               "headers": "", | ||||
|               "fetch_backend": "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Notification Body and Title is required when a Notification URL is used" in res.data | ||||
| #    res = client.post( | ||||
| #        url_for("edit_page", uuid="first"), | ||||
| #        data={"notification_urls": 'json://localhost/foobar', | ||||
| #              "notification_title": "", | ||||
| #              "notification_body": "", | ||||
| #              "notification_format": "Text", | ||||
| #              "url": test_url, | ||||
| #              "tag": "my tag", | ||||
| #              "title": "my title", | ||||
| #              "headers": "", | ||||
| #              "fetch_backend": "html_requests"}, | ||||
| #        follow_redirects=True | ||||
| #    ) | ||||
| #    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 | ||||
|     res = client.post( | ||||
| @@ -215,3 +260,5 @@ def test_notification_validation(client, live_server): | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
| def test_visual_selector_content_ready(client, live_server): | ||||
|     import os | ||||
|     import json | ||||
|  | ||||
|     assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||
|     live_server_setup(live_server) | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page, maybe better to use something we control? | ||||
|     # We use an external URL because the docker container is too difficult to setup to connect back to the pytest socket | ||||
|     test_url = 'https://news.ycombinator.com' | ||||
|     # Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url | ||||
|     test_url = "https://changedetection.io/ci-test/test-runjs.html" | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         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( | ||||
|         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 | ||||
|     ) | ||||
|     assert b"unpaused" in res.data | ||||
|     time.sleep(1) | ||||
|     wait_for_all_checks(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, '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 | ||||
| # (another process inserts watches into the queue that are time-ready for checking) | ||||
|  | ||||
| import logging | ||||
| import sys | ||||
|  | ||||
| class update_worker(threading.Thread): | ||||
|     current_uuid = None | ||||
|  | ||||
|     def __init__(self, q, notification_q, app, datastore, *args, **kwargs): | ||||
|         logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) | ||||
|         self.q = q | ||||
|         self.app = app | ||||
|         self.notification_q = notification_q | ||||
| @@ -26,6 +29,10 @@ class update_worker(threading.Thread): | ||||
|  | ||||
|         from changedetectionio import diff | ||||
|  | ||||
|         from changedetectionio.notification import ( | ||||
|             default_notification_format_for_watch | ||||
|         ) | ||||
|  | ||||
|         n_object = {} | ||||
|         watch = self.datastore.data['watching'].get(watch_uuid, False) | ||||
|         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?" | ||||
|             ) | ||||
|  | ||||
|         # Did it have any notification alerts to hit? | ||||
|         if len(watch['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'] | ||||
|             n_object['notification_body'] = watch['notification_body'] | ||||
|             n_object['notification_format'] = watch['notification_format'] | ||||
|         n_object['notification_urls'] = watch['notification_urls'] if len(watch['notification_urls']) else \ | ||||
|             self.datastore.data['settings']['application']['notification_urls'] | ||||
|  | ||||
|         n_object['notification_title'] = watch['notification_title'] if watch['notification_title'] else \ | ||||
|             self.datastore.data['settings']['application']['notification_title'] | ||||
|  | ||||
|         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 | ||||
|         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 | ||||
|             if n_object['notification_format'] == 'HTML': | ||||
|                 line_feed_sep = "</br>" | ||||
|             else: | ||||
|                 line_feed_sep = "\n" | ||||
|  | ||||
|             snapshot_contents = '' | ||||
|             with open(watch_history[dates[-1]], 'rb') as f: | ||||
|                 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_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) | ||||
|         else: | ||||
|             logging.info (">> NO Notification sent, notification_url was empty in both watch and system") | ||||
|  | ||||
|     def send_filter_failure_notification(self, watch_uuid): | ||||
|  | ||||
| @@ -183,6 +186,9 @@ class update_worker(threading.Thread): | ||||
|                         process_changedetection_results = False | ||||
|  | ||||
|                     except FilterNotFoundInResponse as e: | ||||
|                         if not self.datastore.data['watching'].get(uuid): | ||||
|                             continue | ||||
|  | ||||
|                         err_text = "Warning, filter '{}' not found".format(str(e)) | ||||
|                         self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text, | ||||
|                                                                            # So that we get a trigger when the content is added again | ||||
|   | ||||
| @@ -6,6 +6,8 @@ services: | ||||
|       hostname: changedetection | ||||
|       volumes: | ||||
|         - 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: | ||||
|   #        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 | ||||
|   # | ||||
|   #        Plain requsts - proxy support example. | ||||
|   #        Plain requests - proxy support example. | ||||
|   #      - HTTP_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;` | ||||
|   #        More here https://github.com/dgtlmoon/changedetection.io/wiki/Running-changedetection.io-behind-a-reverse-proxy-sub-directory | ||||
|   #      - 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. | ||||
|       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 | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 209 KiB | 
| @@ -1,8 +1,8 @@ | ||||
| flask~= 2.0 | ||||
| flask ~= 2.0 | ||||
| flask_wtf | ||||
| eventlet>=0.31.0 | ||||
| eventlet >= 0.31.0 | ||||
| validators | ||||
| timeago ~=1.0 | ||||
| timeago ~= 1.0 | ||||
| inscriptis ~= 2.2 | ||||
| feedgen ~= 0.9 | ||||
| flask-login ~= 0.5 | ||||
| @@ -10,15 +10,20 @@ flask_restful | ||||
| pytz | ||||
|  | ||||
| # 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 | ||||
| chardet > 2.3.0 | ||||
|  | ||||
| wtforms ~= 3.0 | ||||
| jsonpath-ng ~= 1.5.3 | ||||
|  | ||||
| # jq not available on Windows so must be installed manually | ||||
|  | ||||
| # Notification library | ||||
| apprise ~= 1.0.0 | ||||
| apprise ~= 1.1.0 | ||||
|  | ||||
| # apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 | ||||
| paho-mqtt | ||||
| @@ -42,3 +47,4 @@ selenium ~= 4.1.0 | ||||
| werkzeug ~= 2.0.0 | ||||
|  | ||||
| # playwright is installed at Dockerfile build time because it's not available on all platforms | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user