mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-11-04 08:34:57 +00:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			dont-versi
			...
			playwright
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					d0146475b6 | 
							
								
								
									
										55
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										55
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,55 +0,0 @@
 | 
			
		||||
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,25 +1,28 @@
 | 
			
		||||
name: ChangeDetection.io App Test
 | 
			
		||||
name: ChangeDetection.io Test
 | 
			
		||||
 | 
			
		||||
# Triggers the workflow on push or pull request events
 | 
			
		||||
on: [push, pull_request]
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  test-application:
 | 
			
		||||
  test-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
 | 
			
		||||
 | 
			
		||||
      - 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
 | 
			
		||||
@@ -36,4 +39,7 @@ 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_notification.py` for example
 | 
			
		||||
Use `pytest` to validate/test, you can run the existing tests as `pytest tests/test_notifications.py` for example
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
pip3 install -r requirements-dev
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								Dockerfile
									
									
									
									
									
								
							@@ -5,14 +5,13 @@ FROM python:3.8-slim as builder
 | 
			
		||||
ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1
 | 
			
		||||
 | 
			
		||||
RUN apt-get update && apt-get install -y --no-install-recommends \
 | 
			
		||||
    g++ \
 | 
			
		||||
    libssl-dev \
 | 
			
		||||
    libffi-dev \
 | 
			
		||||
    gcc \
 | 
			
		||||
    libc-dev \
 | 
			
		||||
    libffi-dev \
 | 
			
		||||
    libssl-dev \
 | 
			
		||||
    libxslt-dev \
 | 
			
		||||
    make \
 | 
			
		||||
    zlib1g-dev
 | 
			
		||||
    zlib1g-dev \
 | 
			
		||||
    g++
 | 
			
		||||
 | 
			
		||||
RUN mkdir /install
 | 
			
		||||
WORKDIR /install
 | 
			
		||||
@@ -21,6 +20,10 @@ COPY requirements.txt /requirements.txt
 | 
			
		||||
 | 
			
		||||
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 \
 | 
			
		||||
    || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled."
 | 
			
		||||
 | 
			
		||||
# Final image stage
 | 
			
		||||
FROM python:3.8-slim
 | 
			
		||||
@@ -55,7 +58,6 @@ EXPOSE 5000
 | 
			
		||||
 | 
			
		||||
# The actual flask app
 | 
			
		||||
COPY changedetectionio /app/changedetectionio
 | 
			
		||||
 | 
			
		||||
# The eventlet server wrapper
 | 
			
		||||
COPY changedetection.py /app/changedetection.py
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@ 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,48 +1,45 @@
 | 
			
		||||
## Web Site Change Detection, Monitoring and Notification.
 | 
			
		||||
#  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>
 | 
			
		||||
 | 
			
		||||
Live your data-life pro-actively, track website content changes and receive notifications via Discord, Email, Slack, Telegram and 70+ more
 | 
			
		||||
## Self-hosted open source change monitoring of web pages.
 | 
			
		||||
 | 
			
		||||
[<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)
 | 
			
		||||
_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.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
[**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) 
 | 
			
		||||
<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. 
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#### Example use cases
 | 
			
		||||
 | 
			
		||||
- 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)
 | 
			
		||||
Know when ...
 | 
			
		||||
 | 
			
		||||
- Government department updates (changes are often only on their websites)
 | 
			
		||||
- Local government news (changes are often only on their websites)
 | 
			
		||||
- New software releases, security advisories when you're not on their mailing list.
 | 
			
		||||
- 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 
 | 
			
		||||
- 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
 | 
			
		||||
- API monitoring and alerting
 | 
			
		||||
 | 
			
		||||
**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`)
 | 
			
		||||
@@ -54,5 +51,17 @@ $ 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.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										41
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										41
									
								
								README.md
									
									
									
									
									
								
							@@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
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?src=github)
 | 
			
		||||
[<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)
 | 
			
		||||
 | 
			
		||||
[![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md)
 | 
			
		||||
 | 
			
		||||
@@ -12,14 +12,11 @@ Know when important content changes, we support notifications via Discord, Teleg
 | 
			
		||||
 | 
			
		||||
[**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.
 | 
			
		||||
 | 
			
		||||
<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 " />
 | 
			
		||||
- Automatic Updates, Automatic Backups, No Heroku "paused application", don't miss a change!
 | 
			
		||||
- Javascript browser included
 | 
			
		||||
- Unlimited checks and watches!
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#### Example use cases
 | 
			
		||||
@@ -47,18 +44,22 @@ _Need an actual Chrome runner with Javascript support? We support fetching via W
 | 
			
		||||
#### 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
 | 
			
		||||
- Target elements with xPath and CSS Selectors, Easily monitor complex JSON with JsonPath rules
 | 
			
		||||
- 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.
 | 
			
		||||
@@ -121,8 +122,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
 | 
			
		||||
@@ -151,7 +152,7 @@ Now you can also customise your notification content!
 | 
			
		||||
 | 
			
		||||
## JSON API Monitoring
 | 
			
		||||
 | 
			
		||||
Detect changes and monitor data in JSON API's by using either JSONPath or jq to filter, parse, and restructure JSON as needed.
 | 
			
		||||
Detect changes and monitor data in JSON API's by using the built-in JSONPath selectors as a filter / selector.
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
@@ -159,17 +160,9 @@ This will re-parse the JSON and apply formatting to the text, making it super ea
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
### JSONPath or jq?
 | 
			
		||||
 | 
			
		||||
For more complex parsing, filtering, and modifying of JSON data, jq is recommended due to the built-in operators and functions. Refer to the [documentation](https://stedolan.github.io/jq/manual/) for more specifc information on jq.
 | 
			
		||||
 | 
			
		||||
One big advantage of `jq` is that you can use logic in your JSON filter, such as filters to only show items that have a value greater than/less than etc.
 | 
			
		||||
 | 
			
		||||
See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/JSON-Selector-Filter-help for more information and examples
 | 
			
		||||
 | 
			
		||||
### Parse JSON embedded in HTML!
 | 
			
		||||
 | 
			
		||||
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. 
 | 
			
		||||
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. 
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
<html>
 | 
			
		||||
@@ -179,11 +172,11 @@ When you enable a `json:` or `jq:` filter, you can even automatically extract an
 | 
			
		||||
</script>
 | 
			
		||||
```  
 | 
			
		||||
 | 
			
		||||
`json:$.price` or `jq:.price` would give `23.50`, or you can extract the whole structure
 | 
			
		||||
`json:$.price` would give `23.50`, or you can extract the whole structure
 | 
			
		||||
 | 
			
		||||
## Proxy Configuration
 | 
			
		||||
## Proxy configuration
 | 
			
		||||
 | 
			
		||||
See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration , we also support using [BrightData proxy services where possible]( https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support)
 | 
			
		||||
See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration
 | 
			
		||||
 | 
			
		||||
## Raspberry Pi support?
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,16 @@
 | 
			
		||||
#!/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
 | 
			
		||||
@@ -33,7 +44,7 @@ from flask_wtf import CSRFProtect
 | 
			
		||||
from changedetectionio import html_tools
 | 
			
		||||
from changedetectionio.api import api_v1
 | 
			
		||||
 | 
			
		||||
__version__ = '0.39.20.4'
 | 
			
		||||
__version__ = '0.39.18'
 | 
			
		||||
 | 
			
		||||
datastore = None
 | 
			
		||||
 | 
			
		||||
@@ -194,8 +205,7 @@ 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})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -493,7 +503,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        from changedetectionio import fetch_site_status
 | 
			
		||||
 | 
			
		||||
        # Get the most recent one
 | 
			
		||||
        newest_history_key = datastore.data['watching'][uuid].get('newest_history_key')
 | 
			
		||||
        newest_history_key = datastore.get_val(uuid, 'newest_history_key')
 | 
			
		||||
 | 
			
		||||
        # 0 means that theres only one, so that there should be no 'unviewed' history available
 | 
			
		||||
        if newest_history_key == 0:
 | 
			
		||||
@@ -542,13 +552,16 @@ 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'] = ''
 | 
			
		||||
@@ -562,10 +575,7 @@ 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')]
 | 
			
		||||
            for p in datastore.proxy_list:
 | 
			
		||||
                form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label'])))
 | 
			
		||||
 | 
			
		||||
            form.proxy.choices = [('', 'Default')] + datastore.proxy_list
 | 
			
		||||
 | 
			
		||||
        if request.method == 'POST' and form.validate():
 | 
			
		||||
            extra_update_obj = {}
 | 
			
		||||
@@ -588,8 +598,10 @@ 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
 | 
			
		||||
 | 
			
		||||
@@ -637,27 +649,18 @@ 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,
 | 
			
		||||
                                     watch=datastore.data['watching'][uuid],
 | 
			
		||||
                                     playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False)
 | 
			
		||||
                                     )
 | 
			
		||||
 | 
			
		||||
        return output
 | 
			
		||||
@@ -669,34 +672,26 @@ 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 system_proxy in available_proxies:
 | 
			
		||||
            if not any([system_proxy in tup for tup in datastore.proxy_list]):
 | 
			
		||||
                system_proxy = None
 | 
			
		||||
 | 
			
		||||
            default['requests']['proxy'] = system_proxy if system_proxy is not None else available_proxies[0]
 | 
			
		||||
            default['requests']['proxy'] = system_proxy if system_proxy is not None else datastore.proxy_list[0][0]
 | 
			
		||||
            # Used by the form handler to keep or remove the proxy settings
 | 
			
		||||
            default['proxy_list'] = available_proxies[0]
 | 
			
		||||
            default['proxy_list'] = datastore.proxy_list
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        # 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 = []
 | 
			
		||||
            for p in datastore.proxy_list:
 | 
			
		||||
                form.requests.form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label'])))
 | 
			
		||||
 | 
			
		||||
            form.requests.form.proxy.choices = datastore.proxy_list
 | 
			
		||||
 | 
			
		||||
        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
 | 
			
		||||
@@ -737,8 +732,7 @@ 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),
 | 
			
		||||
                                 settings_application=datastore.data['settings']['application'])
 | 
			
		||||
                                 emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False))
 | 
			
		||||
 | 
			
		||||
        return output
 | 
			
		||||
 | 
			
		||||
@@ -817,10 +811,8 @@ 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', encoding='utf-8', errors='ignore') as f:
 | 
			
		||||
            with open(newest_file, 'r') as f:
 | 
			
		||||
                newest_version_file_contents = f.read()
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            newest_version_file_contents = "Unable to read {}.\n".format(newest_file)
 | 
			
		||||
@@ -833,7 +825,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            previous_file = history[dates[-2]]
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            with open(previous_file, 'r', encoding='utf-8', errors='ignore') as f:
 | 
			
		||||
            with open(previous_file, 'r') as f:
 | 
			
		||||
                previous_version_file_contents = f.read()
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            previous_version_file_contents = "Unable to read {}.\n".format(previous_file)
 | 
			
		||||
@@ -910,7 +902,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        timestamp = list(watch.history.keys())[-1]
 | 
			
		||||
        filename = watch.history[timestamp]
 | 
			
		||||
        try:
 | 
			
		||||
            with open(filename, 'r', encoding='utf-8', errors='ignore') as f:
 | 
			
		||||
            with open(filename, 'r') as f:
 | 
			
		||||
                tmp = f.readlines()
 | 
			
		||||
 | 
			
		||||
                # Get what needs to be highlighted
 | 
			
		||||
@@ -985,6 +977,9 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
        # create a ZipFile object
 | 
			
		||||
        backupname = "changedetection-backup-{}.zip".format(int(time.time()))
 | 
			
		||||
 | 
			
		||||
        # We only care about UUIDS from the current index file
 | 
			
		||||
        uuids = list(datastore.data['watching'].keys())
 | 
			
		||||
        backup_filepath = os.path.join(datastore_o.datastore_path, backupname)
 | 
			
		||||
 | 
			
		||||
        with zipfile.ZipFile(backup_filepath, "w",
 | 
			
		||||
@@ -1000,12 +995,12 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            # Add the flask app secret
 | 
			
		||||
            zipObj.write(os.path.join(datastore_o.datastore_path, "secret.txt"), arcname="secret.txt")
 | 
			
		||||
 | 
			
		||||
            # Add any data in the watch data directory.
 | 
			
		||||
            for uuid, w in datastore.data['watching'].items():
 | 
			
		||||
                for f in Path(w.watch_data_dir).glob('*'):
 | 
			
		||||
                    zipObj.write(f,
 | 
			
		||||
                                 # Use the full path to access the file, but make the file 'relative' in the Zip.
 | 
			
		||||
                                 arcname=os.path.join(f.parts[-2], f.parts[-1]),
 | 
			
		||||
            # Add any snapshot data we find, use the full path to access the file, but make the file 'relative' in the Zip.
 | 
			
		||||
            for txt_file_path in Path(datastore_o.datastore_path).rglob('*.txt'):
 | 
			
		||||
                parent_p = txt_file_path.parent
 | 
			
		||||
                if parent_p.name in uuids:
 | 
			
		||||
                    zipObj.write(txt_file_path,
 | 
			
		||||
                                 arcname=str(txt_file_path).replace(datastore_o.datastore_path, ''),
 | 
			
		||||
                                 compress_type=zipfile.ZIP_DEFLATED,
 | 
			
		||||
                                 compresslevel=8)
 | 
			
		||||
 | 
			
		||||
@@ -1204,7 +1199,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                    datastore.delete(uuid.strip())
 | 
			
		||||
            flash("{} watches deleted".format(len(uuids)))
 | 
			
		||||
 | 
			
		||||
        elif (op == 'pause'):
 | 
			
		||||
        if (op == 'pause'):
 | 
			
		||||
            for uuid in uuids:
 | 
			
		||||
                uuid = uuid.strip()
 | 
			
		||||
                if datastore.data['watching'].get(uuid):
 | 
			
		||||
@@ -1212,40 +1207,13 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
            flash("{} watches paused".format(len(uuids)))
 | 
			
		||||
 | 
			
		||||
        elif (op == 'unpause'):
 | 
			
		||||
        if (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'])
 | 
			
		||||
@@ -1307,8 +1275,8 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
    threading.Thread(target=notification_runner).start()
 | 
			
		||||
 | 
			
		||||
    # Check for new release version, but not when running in test/build or pytest
 | 
			
		||||
    if not os.getenv("GITHUB_REF", False) and not config.get('disable_checkver') == True:
 | 
			
		||||
    # Check for new release version, but not when running in test/build
 | 
			
		||||
    if not os.getenv("GITHUB_REF", False):
 | 
			
		||||
        threading.Thread(target=check_for_new_version).start()
 | 
			
		||||
 | 
			
		||||
    return app
 | 
			
		||||
@@ -1383,8 +1351,6 @@ 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)
 | 
			
		||||
 | 
			
		||||
@@ -1445,30 +1411,10 @@ 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']
 | 
			
		||||
 | 
			
		||||
            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]:
 | 
			
		||||
 | 
			
		||||
                    # 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(
 | 
			
		||||
 
 | 
			
		||||
@@ -122,37 +122,3 @@ 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:
 | 
			
		||||
                # Use the system wide default
 | 
			
		||||
                t = self.datastore.threshold_seconds
 | 
			
		||||
 | 
			
		||||
            time_since_check = time.time() - watch.get('last_checked')
 | 
			
		||||
 | 
			
		||||
            # Allow 5 minutes of grace time before we decide it's overdue
 | 
			
		||||
            if time_since_check - (5 * 60) > t:
 | 
			
		||||
                overdue_watches.append(uuid)
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
                   'queue_size': self.update_q.qsize(),
 | 
			
		||||
                   'overdue_watches': overdue_watches,
 | 
			
		||||
                   'uptime': round(time.time() - self.datastore.start_time, 2),
 | 
			
		||||
                   'watch_count': len(self.datastore.data.get('watching', {}))
 | 
			
		||||
               }, 200
 | 
			
		||||
 
 | 
			
		||||
@@ -102,14 +102,6 @@ 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,7 +316,6 @@ 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)
 | 
			
		||||
 | 
			
		||||
@@ -374,11 +373,8 @@ class base_html_playwright(Fetcher):
 | 
			
		||||
                print("response object was none")
 | 
			
		||||
                raise EmptyReply(url=url, status_code=None)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            # 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
 | 
			
		||||
                        
 | 
			
		||||
            # Bug 2(?) Set the viewport size AFTER loading the page
 | 
			
		||||
            page.set_viewport_size({"width": 1280, "height": 1024})
 | 
			
		||||
            extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
 | 
			
		||||
            time.sleep(extra_wait)
 | 
			
		||||
 | 
			
		||||
@@ -402,13 +398,6 @@ 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()
 | 
			
		||||
@@ -418,7 +407,8 @@ class base_html_playwright(Fetcher):
 | 
			
		||||
            else:
 | 
			
		||||
                page.evaluate("var css_filter=''")
 | 
			
		||||
 | 
			
		||||
            self.xpath_data = page.evaluate("async () => {" + self.xpath_element_js + "}")
 | 
			
		||||
            # str() here must create a dereferenced copy, which allows the GC to release correctly
 | 
			
		||||
            self.xpath_data = str(page.evaluate("async () => {" + self.xpath_element_js + "}"))
 | 
			
		||||
 | 
			
		||||
            # Bug 3 in Playwright screenshot handling
 | 
			
		||||
            # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
 | 
			
		||||
@@ -525,6 +515,8 @@ 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
 | 
			
		||||
@@ -535,8 +527,6 @@ 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
 | 
			
		||||
@@ -575,11 +565,6 @@ 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,6 +20,34 @@ 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):
 | 
			
		||||
@@ -35,13 +63,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'].get(uuid)
 | 
			
		||||
        if not watch:
 | 
			
		||||
            return
 | 
			
		||||
        watch = self.datastore.data['watching'][uuid]
 | 
			
		||||
 | 
			
		||||
        # Protect against file:// access
 | 
			
		||||
        if re.search(r'^file', watch['url'], re.IGNORECASE) and not os.getenv('ALLOW_FILE_URI', False):
 | 
			
		||||
@@ -52,7 +80,7 @@ class perform_site_check():
 | 
			
		||||
        # Unset any existing notification error
 | 
			
		||||
        update_obj = {'last_notification_error': False, 'last_error': False}
 | 
			
		||||
 | 
			
		||||
        extra_headers =self.datastore.data['watching'][uuid].get('headers')
 | 
			
		||||
        extra_headers = self.datastore.get_val(uuid, 'headers')
 | 
			
		||||
 | 
			
		||||
        # Tweak the base config with the per-watch ones
 | 
			
		||||
        request_headers = self.datastore.data['settings']['headers'].copy()
 | 
			
		||||
@@ -64,12 +92,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'].get('timeout')
 | 
			
		||||
 | 
			
		||||
        url = watch.link
 | 
			
		||||
 | 
			
		||||
        request_body = self.datastore.data['watching'][uuid].get('body')
 | 
			
		||||
        request_method = self.datastore.data['watching'][uuid].get('method')
 | 
			
		||||
        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')
 | 
			
		||||
        ignore_status_codes = self.datastore.data['watching'][uuid].get('ignore_status_codes', False)
 | 
			
		||||
 | 
			
		||||
        # source: support
 | 
			
		||||
@@ -86,13 +112,9 @@ 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))
 | 
			
		||||
 | 
			
		||||
        fetcher = klass(proxy_override=proxy_url)
 | 
			
		||||
        proxy_args = self.set_proxy_from_list(watch)
 | 
			
		||||
        fetcher = klass(proxy_override=proxy_args)
 | 
			
		||||
 | 
			
		||||
        # 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)
 | 
			
		||||
@@ -143,9 +165,8 @@ class perform_site_check():
 | 
			
		||||
            has_filter_rule = True
 | 
			
		||||
 | 
			
		||||
        if has_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)
 | 
			
		||||
            if 'json:' in css_filter_rule:
 | 
			
		||||
                stripped_text_from_html = html_tools.extract_json_as_string(content=fetcher.content, jsonpath_filter=css_filter_rule)
 | 
			
		||||
                is_html = False
 | 
			
		||||
 | 
			
		||||
        if is_html or is_source:
 | 
			
		||||
@@ -185,6 +206,9 @@ class perform_site_check():
 | 
			
		||||
                elif is_source:
 | 
			
		||||
                    stripped_text_from_html = html_content
 | 
			
		||||
 | 
			
		||||
            # Re #340 - return the content before the 'ignore text' was applied
 | 
			
		||||
            text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8')
 | 
			
		||||
 | 
			
		||||
        # Re #340 - return the content before the 'ignore text' was applied
 | 
			
		||||
        text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8')
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -303,25 +303,6 @@ 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):
 | 
			
		||||
@@ -333,14 +314,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(), 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())
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
    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):
 | 
			
		||||
 | 
			
		||||
@@ -374,8 +355,6 @@ 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,35 +79,19 @@ def extract_element(find='title', html_content=''):
 | 
			
		||||
    return element_text
 | 
			
		||||
 | 
			
		||||
#
 | 
			
		||||
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)
 | 
			
		||||
def _parse_json(json_data, jsonpath_filter):
 | 
			
		||||
    s=[]
 | 
			
		||||
    jsonpath_expression = parse(jsonpath_filter.replace('json:', ''))
 | 
			
		||||
    match = jsonpath_expression.find(json_data)
 | 
			
		||||
 | 
			
		||||
    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 if hasattr(i, 'value') else i)
 | 
			
		||||
            s.append(i.value)
 | 
			
		||||
 | 
			
		||||
    # 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 if hasattr(match[0], 'value') else match[0]
 | 
			
		||||
        s = match[0].value
 | 
			
		||||
 | 
			
		||||
    # Re #257 - Better handling where it does not exist, in the case the original 's' value was False..
 | 
			
		||||
    if not match:
 | 
			
		||||
@@ -119,16 +103,16 @@ def _get_stripped_text_from_json_match(match):
 | 
			
		||||
 | 
			
		||||
    return stripped_text_from_html
 | 
			
		||||
 | 
			
		||||
def extract_json_as_string(content, json_filter):
 | 
			
		||||
def extract_json_as_string(content, jsonpath_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), json_filter)
 | 
			
		||||
        stripped_text_from_html = _parse_json(json.loads(content), jsonpath_filter)
 | 
			
		||||
    except json.JSONDecodeError:
 | 
			
		||||
 | 
			
		||||
        # Foreach <script json></script> blob.. just return the first that matches json_filter
 | 
			
		||||
        # Foreach <script json></script> blob.. just return the first that matches jsonpath_filter
 | 
			
		||||
        s = []
 | 
			
		||||
        soup = BeautifulSoup(content, 'html.parser')
 | 
			
		||||
        bs_result = soup.findAll('script')
 | 
			
		||||
@@ -147,7 +131,7 @@ def extract_json_as_string(content, json_filter):
 | 
			
		||||
                # Just skip it
 | 
			
		||||
                continue
 | 
			
		||||
            else:
 | 
			
		||||
                stripped_text_from_html = _parse_json(json_data, json_filter)
 | 
			
		||||
                stripped_text_from_html = _parse_json(json_data, jsonpath_filter)
 | 
			
		||||
                if stripped_text_from_html:
 | 
			
		||||
                    break
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,10 @@ 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
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,14 @@
 | 
			
		||||
from distutils.util import strtobool
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
import uuid
 | 
			
		||||
import uuid as uuid_builder
 | 
			
		||||
from distutils.util import strtobool
 | 
			
		||||
 | 
			
		||||
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_format_for_watch
 | 
			
		||||
    default_notification_body,
 | 
			
		||||
    default_notification_format,
 | 
			
		||||
    default_notification_title,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -24,7 +24,7 @@ class model(dict):
 | 
			
		||||
            #'newest_history_key': 0,
 | 
			
		||||
            'title': None,
 | 
			
		||||
            'previous_md5': False,
 | 
			
		||||
            'uuid': str(uuid.uuid4()),
 | 
			
		||||
            'uuid': str(uuid_builder.uuid4()),
 | 
			
		||||
            'headers': {},  # Extra headers to send
 | 
			
		||||
            'body': None,
 | 
			
		||||
            'method': 'GET',
 | 
			
		||||
@@ -32,9 +32,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': None,
 | 
			
		||||
            'notification_body': None,
 | 
			
		||||
            'notification_format': default_notification_format_for_watch,
 | 
			
		||||
            'notification_title': default_notification_title,
 | 
			
		||||
            'notification_body': default_notification_body,
 | 
			
		||||
            'notification_format': default_notification_format,
 | 
			
		||||
            'notification_muted': False,
 | 
			
		||||
            'css_filter': '',
 | 
			
		||||
            'last_error': False,
 | 
			
		||||
@@ -62,7 +62,7 @@ class model(dict):
 | 
			
		||||
        self.update(self.__base_config)
 | 
			
		||||
        self.__datastore_path = kw['datastore_path']
 | 
			
		||||
 | 
			
		||||
        self['uuid'] = str(uuid.uuid4())
 | 
			
		||||
        self['uuid'] = str(uuid_builder.uuid4())
 | 
			
		||||
 | 
			
		||||
        del kw['datastore_path']
 | 
			
		||||
 | 
			
		||||
@@ -84,19 +84,10 @@ class model(dict):
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    def ensure_data_dir_exists(self):
 | 
			
		||||
        if not os.path.isdir(self.watch_data_dir):
 | 
			
		||||
            print ("> Creating data dir {}".format(self.watch_data_dir))
 | 
			
		||||
            os.mkdir(self.watch_data_dir)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def link(self):
 | 
			
		||||
        url = self.get('url', '')
 | 
			
		||||
        if '{%' in url or '{{' in url:
 | 
			
		||||
            from jinja2 import Environment
 | 
			
		||||
            # Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/
 | 
			
		||||
            jinja2_env = Environment(extensions=['jinja2_time.TimeExtension'])
 | 
			
		||||
            return str(jinja2_env.from_string(url).render())
 | 
			
		||||
        return url
 | 
			
		||||
        target_path = os.path.join(self.__datastore_path, self['uuid'])
 | 
			
		||||
        if not os.path.isdir(target_path):
 | 
			
		||||
            print ("> Creating data dir {}".format(target_path))
 | 
			
		||||
            os.mkdir(target_path)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def label(self):
 | 
			
		||||
@@ -120,40 +111,16 @@ class model(dict):
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def history(self):
 | 
			
		||||
        """History index is just a text file as a list
 | 
			
		||||
            {watch-uuid}/history.txt
 | 
			
		||||
 | 
			
		||||
            contains a list like
 | 
			
		||||
 | 
			
		||||
            {epoch-time},{filename}\n
 | 
			
		||||
 | 
			
		||||
            We read in this list as the history information
 | 
			
		||||
 | 
			
		||||
        """
 | 
			
		||||
        tmp_history = {}
 | 
			
		||||
        import logging
 | 
			
		||||
        import time
 | 
			
		||||
 | 
			
		||||
        # Read the history file as a dict
 | 
			
		||||
        fname = os.path.join(self.watch_data_dir, "history.txt")
 | 
			
		||||
        fname = os.path.join(self.__datastore_path, self.get('uuid'), "history.txt")
 | 
			
		||||
        if os.path.isfile(fname):
 | 
			
		||||
            logging.debug("Reading history index " + str(time.time()))
 | 
			
		||||
            with open(fname, "r") as f:
 | 
			
		||||
                for i in f.readlines():
 | 
			
		||||
                    if ',' in i:
 | 
			
		||||
                        k, v = i.strip().split(',', 2)
 | 
			
		||||
 | 
			
		||||
                        # The index history could contain a relative path, so we need to make the fullpath
 | 
			
		||||
                        # so that python can read it
 | 
			
		||||
                        if not '/' in v and not '\'' in v:
 | 
			
		||||
                            v = os.path.join(self.watch_data_dir, v)
 | 
			
		||||
                        else:
 | 
			
		||||
                            # It's possible that they moved the datadir on older versions
 | 
			
		||||
                            # So the snapshot exists but is in a different path
 | 
			
		||||
                            snapshot_fname = v.split('/')[-1]
 | 
			
		||||
                            proposed_new_path = os.path.join(self.watch_data_dir, snapshot_fname)
 | 
			
		||||
                            if not os.path.exists(v) and os.path.exists(proposed_new_path):
 | 
			
		||||
                                v = proposed_new_path
 | 
			
		||||
 | 
			
		||||
                        tmp_history[k] = v
 | 
			
		||||
                tmp_history = dict(i.strip().split(',', 2) for i in f.readlines())
 | 
			
		||||
 | 
			
		||||
        if len(tmp_history):
 | 
			
		||||
            self.__newest_history_key = list(tmp_history.keys())[-1]
 | 
			
		||||
@@ -164,7 +131,7 @@ class model(dict):
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def has_history(self):
 | 
			
		||||
        fname = os.path.join(self.watch_data_dir, "history.txt")
 | 
			
		||||
        fname = os.path.join(self.__datastore_path, self.get('uuid'), "history.txt")
 | 
			
		||||
        return os.path.isfile(fname)
 | 
			
		||||
 | 
			
		||||
    # Returns the newest key, but if theres only 1 record, then it's counted as not being new, so return 0.
 | 
			
		||||
@@ -183,33 +150,31 @@ class model(dict):
 | 
			
		||||
    # Save some text file to the appropriate path and bump the history
 | 
			
		||||
    # result_obj from fetch_site_status.run()
 | 
			
		||||
    def save_history_text(self, contents, timestamp):
 | 
			
		||||
        import uuid
 | 
			
		||||
        import logging
 | 
			
		||||
 | 
			
		||||
        output_path = "{}/{}".format(self.__datastore_path, self['uuid'])
 | 
			
		||||
 | 
			
		||||
        self.ensure_data_dir_exists()
 | 
			
		||||
 | 
			
		||||
        # Small hack so that we sleep just enough to allow 1 second  between history snapshots
 | 
			
		||||
        # this is because history.txt indexes/keys snapshots by epoch seconds and we dont want dupe keys
 | 
			
		||||
        if self.__newest_history_key and int(timestamp) == int(self.__newest_history_key):
 | 
			
		||||
            time.sleep(timestamp - self.__newest_history_key)
 | 
			
		||||
        snapshot_fname = "{}/{}.stripped.txt".format(output_path, uuid.uuid4())
 | 
			
		||||
        logging.debug("Saving history text {}".format(snapshot_fname))
 | 
			
		||||
 | 
			
		||||
        snapshot_fname = "{}.txt".format(str(uuid.uuid4()))
 | 
			
		||||
 | 
			
		||||
        # in /diff/ and /preview/ we are going to assume for now that it's UTF-8 when reading
 | 
			
		||||
        # most sites are utf-8 and some are even broken utf-8
 | 
			
		||||
        with open(os.path.join(self.watch_data_dir, snapshot_fname), 'wb') as f:
 | 
			
		||||
        with open(snapshot_fname, 'wb') as f:
 | 
			
		||||
            f.write(contents)
 | 
			
		||||
            f.close()
 | 
			
		||||
 | 
			
		||||
        # Append to index
 | 
			
		||||
        # @todo check last char was \n
 | 
			
		||||
        index_fname = os.path.join(self.watch_data_dir, "history.txt")
 | 
			
		||||
        index_fname = "{}/history.txt".format(output_path)
 | 
			
		||||
        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
 | 
			
		||||
@@ -242,14 +207,14 @@ class model(dict):
 | 
			
		||||
        return not local_lines.issubset(existing_history)
 | 
			
		||||
 | 
			
		||||
    def get_screenshot(self):
 | 
			
		||||
        fname = os.path.join(self.watch_data_dir, "last-screenshot.png")
 | 
			
		||||
        fname = os.path.join(self.__datastore_path, self['uuid'], "last-screenshot.png")
 | 
			
		||||
        if os.path.isfile(fname):
 | 
			
		||||
            return fname
 | 
			
		||||
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    def __get_file_ctime(self, filename):
 | 
			
		||||
        fname = os.path.join(self.watch_data_dir, filename)
 | 
			
		||||
        fname = os.path.join(self.__datastore_path, self['uuid'], filename)
 | 
			
		||||
        if os.path.isfile(fname):
 | 
			
		||||
            return int(os.path.getmtime(fname))
 | 
			
		||||
        return False
 | 
			
		||||
@@ -274,14 +239,9 @@ class model(dict):
 | 
			
		||||
    def snapshot_error_screenshot_ctime(self):
 | 
			
		||||
        return self.__get_file_ctime('last-error-screenshot.png')
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def watch_data_dir(self):
 | 
			
		||||
        # The base dir of the watch data
 | 
			
		||||
        return os.path.join(self.__datastore_path, self['uuid'])
 | 
			
		||||
    
 | 
			
		||||
    def get_error_text(self):
 | 
			
		||||
        """Return the text saved from a previous request that resulted in a non-200 error"""
 | 
			
		||||
        fname = os.path.join(self.watch_data_dir, "last-error.txt")
 | 
			
		||||
        fname = os.path.join(self.__datastore_path, self['uuid'], "last-error.txt")
 | 
			
		||||
        if os.path.isfile(fname):
 | 
			
		||||
            with open(fname, 'r') as f:
 | 
			
		||||
                return f.read()
 | 
			
		||||
@@ -289,7 +249,7 @@ class model(dict):
 | 
			
		||||
 | 
			
		||||
    def get_error_snapshot(self):
 | 
			
		||||
        """Return path to the screenshot that resulted in a non-200 error"""
 | 
			
		||||
        fname = os.path.join(self.watch_data_dir, "last-error-screenshot.png")
 | 
			
		||||
        fname = os.path.join(self.__datastore_path, self['uuid'], "last-error-screenshot.png")
 | 
			
		||||
        if os.path.isfile(fname):
 | 
			
		||||
            return fname
 | 
			
		||||
        return False
 | 
			
		||||
 
 | 
			
		||||
@@ -14,19 +14,16 @@ 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,8 +9,6 @@
 | 
			
		||||
# 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"
 | 
			
		||||
@@ -24,6 +22,7 @@ echo "RUNNING WITH BASE_URL SET"
 | 
			
		||||
export BASE_URL="https://really-unique-domain.io"
 | 
			
		||||
pytest tests/test_notification.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,6 +37,8 @@ unset WEBDRIVER_URL
 | 
			
		||||
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
 | 
			
		||||
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
 | 
			
		||||
@@ -47,48 +48,4 @@ pytest tests/test_errorhandling.py
 | 
			
		||||
pytest tests/visualselector/test_fetch_data.py
 | 
			
		||||
 | 
			
		||||
unset PLAYWRIGHT_DRIVER_URL
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
docker kill $$-test_browserless
 | 
			
		||||
@@ -1,51 +0,0 @@
 | 
			
		||||
<?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>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 2.4 KiB  | 
@@ -1,122 +0,0 @@
 | 
			
		||||
<?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>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 3.5 KiB  | 
@@ -30,11 +30,4 @@ $(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();
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -565,16 +565,3 @@ ul {
 | 
			
		||||
 | 
			
		||||
.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; }
 | 
			
		||||
 
 | 
			
		||||
@@ -156,7 +156,7 @@ body:after, body:before {
 | 
			
		||||
 | 
			
		||||
.fetch-error {
 | 
			
		||||
  padding-top: 1em;
 | 
			
		||||
  font-size: 80%;
 | 
			
		||||
  font-size: 60%;
 | 
			
		||||
  max-width: 400px;
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
@@ -786,21 +786,3 @@ ul {
 | 
			
		||||
    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;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,18 +27,17 @@ class ChangeDetectionStore:
 | 
			
		||||
    # For when we edit, we should write to disk
 | 
			
		||||
    needs_write_urgent = False
 | 
			
		||||
 | 
			
		||||
    __version_check = True
 | 
			
		||||
 | 
			
		||||
    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.__data = App.model()
 | 
			
		||||
        self.needs_write = False
 | 
			
		||||
        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={}))
 | 
			
		||||
@@ -82,6 +81,8 @@ 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')
 | 
			
		||||
 | 
			
		||||
@@ -112,7 +113,9 @@ class ChangeDetectionStore:
 | 
			
		||||
            self.__data['settings']['application']['api_access_token'] = secret
 | 
			
		||||
 | 
			
		||||
        # Proxy list support - available as a selection in settings when text file is imported
 | 
			
		||||
        proxy_list_file = "{}/proxies.json".format(self.datastore_path)
 | 
			
		||||
        # CSV list
 | 
			
		||||
        # "name, address", or just "name"
 | 
			
		||||
        proxy_list_file = "{}/proxies.txt".format(self.datastore_path)
 | 
			
		||||
        if path.isfile(proxy_list_file):
 | 
			
		||||
            self.import_proxy_list(proxy_list_file)
 | 
			
		||||
 | 
			
		||||
@@ -241,6 +244,10 @@ 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
 | 
			
		||||
@@ -434,42 +441,20 @@ class ChangeDetectionStore:
 | 
			
		||||
                    unlink(item)
 | 
			
		||||
 | 
			
		||||
    def import_proxy_list(self, filename):
 | 
			
		||||
        with open(filename) as f:
 | 
			
		||||
            self.proxy_list = json.load(f)
 | 
			
		||||
            print ("Registered proxy list", list(self.proxy_list.keys()))
 | 
			
		||||
        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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
@@ -554,33 +539,4 @@ class ChangeDetectionStore:
 | 
			
		||||
                del(watch['last_changed'])
 | 
			
		||||
            except:
 | 
			
		||||
                continue
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    def update_5(self):
 | 
			
		||||
        # If the watch notification body, title look the same as the global one, unset it, so the watch defaults back to using the main settings
 | 
			
		||||
        # In other words - the watch notification_title and notification_body are not needed if they are the same as the default one
 | 
			
		||||
        current_system_body = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n "))
 | 
			
		||||
        current_system_title = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n "))
 | 
			
		||||
        for uuid, watch in self.data['watching'].items():
 | 
			
		||||
            try:
 | 
			
		||||
                watch_body = watch.get('notification_body', '')
 | 
			
		||||
                if watch_body and watch_body.translate(str.maketrans('', '', "\r\n ")) == current_system_body:
 | 
			
		||||
                    # Looks the same as the default one, so unset it
 | 
			
		||||
                    watch['notification_body'] = None
 | 
			
		||||
 | 
			
		||||
                watch_title = watch.get('notification_title', '')
 | 
			
		||||
                if watch_title and watch_title.translate(str.maketrans('', '', "\r\n ")) == current_system_title:
 | 
			
		||||
                    # Looks the same as the default one, so unset it
 | 
			
		||||
                    watch['notification_title'] = None
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                continue
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # We incorrectly used common header overrides that should only apply to Requests
 | 
			
		||||
    # These are now handled in content_fetcher::html_requests and shouldnt be passed to Playwright/Selenium
 | 
			
		||||
    def update_7(self):
 | 
			
		||||
        # These were hard-coded in early versions
 | 
			
		||||
        for v in ['User-Agent', 'Accept', 'Accept-Encoding', 'Accept-Language']:
 | 
			
		||||
            if self.data['settings']['headers'].get(v):
 | 
			
		||||
                del self.data['settings']['headers'][v]
 | 
			
		||||
        return
 | 
			
		||||
@@ -1,14 +1,13 @@
 | 
			
		||||
 | 
			
		||||
{% from '_helpers.jinja' import render_field %}
 | 
			
		||||
 | 
			
		||||
{% macro render_common_settings_form(form, emailprefix, settings_application) %}
 | 
			
		||||
{% macro render_common_settings_form(form, current_base_url, emailprefix) %}
 | 
			
		||||
                        <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>
 | 
			
		||||
@@ -27,16 +26,15 @@
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div id="notification-customisation" class="pure-control-group">
 | 
			
		||||
                            <div class="pure-control-group">
 | 
			
		||||
                                {{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }}
 | 
			
		||||
                                {{ render_field(form.notification_title, class="m-d 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", placeholder=settings_application['notification_body']) }}
 | 
			
		||||
                                {{ render_field(form.notification_body , rows=5, class="notification-body") }}
 | 
			
		||||
                                <span class="pure-form-message-inline">Body for all notifications</span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="pure-control-group">
 | 
			
		||||
                            <!-- unsure -->
 | 
			
		||||
                                {{ render_field(form.notification_format , class="notification-format") }}
 | 
			
		||||
                                {{ render_field(form.notification_format , rows=5, class="notification-format") }}
 | 
			
		||||
                                <span class="pure-form-message-inline">Format for all notifications</span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="pure-controls">
 | 
			
		||||
@@ -96,7 +94,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 "{{settings_application['current_base_url']}}"
 | 
			
		||||
                                Your <code>BASE_URL</code> var is currently "{{current_base_url}}"
 | 
			
		||||
                            </span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -40,8 +40,7 @@
 | 
			
		||||
                <fieldset>
 | 
			
		||||
                    <div class="pure-control-group">
 | 
			
		||||
                        {{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }}
 | 
			
		||||
                        <span class="pure-form-message-inline">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></span><br/>
 | 
			
		||||
                        <span class="pure-form-message-inline">You can use variables in the URL, perfect for inserting the current date and other logic, <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a></span><br/>
 | 
			
		||||
                        <span class="pure-form-message-inline">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="pure-control-group">
 | 
			
		||||
                        {{ render_field(form.title, class="m-d") }}
 | 
			
		||||
@@ -78,7 +77,6 @@
 | 
			
		||||
                        <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 %}
 | 
			
		||||
@@ -137,20 +135,10 @@ 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="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 class="field-group">
 | 
			
		||||
                        {{ render_common_settings_form(form, current_base_url, emailprefix) }}
 | 
			
		||||
                    </div>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
            </div>
 | 
			
		||||
@@ -185,16 +173,8 @@ 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 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>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>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
 | 
			
		||||
@@ -203,7 +183,7 @@ User-Agent: wonderbra 1.0") }}
 | 
			
		||||
                            </ul>
 | 
			
		||||
                            </li>
 | 
			
		||||
                    </ul>
 | 
			
		||||
                    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
 | 
			
		||||
                    Please be sure that you thoroughly understand how to write CSS or JSONPath, XPath selector 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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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 <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']}}"),
 | 
			
		||||
                            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}}"),
 | 
			
		||||
                            <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, emailprefix, settings_application) }}
 | 
			
		||||
                        {{ render_common_settings_form(form.application.form, current_base_url, emailprefix) }}
 | 
			
		||||
                    </div>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
            </div>
 | 
			
		||||
@@ -99,8 +99,6 @@
 | 
			
		||||
                        <p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p>
 | 
			
		||||
                        <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p>
 | 
			
		||||
                    </span>
 | 
			
		||||
                    <br/>
 | 
			
		||||
                    Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a>
 | 
			
		||||
                </div>
 | 
			
		||||
                <fieldset class="pure-group" id="webdriver-override-options">
 | 
			
		||||
                    <div class="pure-form-message-inline">
 | 
			
		||||
 
 | 
			
		||||
@@ -30,9 +30,6 @@
 | 
			
		||||
    <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>
 | 
			
		||||
@@ -79,15 +76,11 @@
 | 
			
		||||
                {% if watch.uuid in queued_uuids %}queued{% endif %}">
 | 
			
		||||
                <td class="inline checkbox-uuid" ><input name="uuids"  type="checkbox" value="{{ watch.uuid}} "/> <span>{{ loop.index }}</span></td>
 | 
			
		||||
                <td class="inline watch-controls">
 | 
			
		||||
                    {% if not watch.paused %}
 | 
			
		||||
                    <a class="state-off" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks"/></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.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>
 | 
			
		||||
                    <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}}
 | 
			
		||||
                    <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a>
 | 
			
		||||
                    <a class="external" target="_blank" rel="noopener" href="{{ watch.url.replace('source:','') }}"></a>
 | 
			
		||||
                    <a href="{{url_for('form_share_put_watch', uuid=watch.uuid)}}"><img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread.svg')}}" /></a>
 | 
			
		||||
 | 
			
		||||
                    {%if watch.fetch_backend == "html_webdriver" %}<img style="height: 1em; display:inline-block;" src="{{url_for('static_content', group='images', filename='Google-Chrome-icon.png')}}" />{% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -41,7 +41,7 @@ def app(request):
 | 
			
		||||
 | 
			
		||||
    cleanup(datastore_path)
 | 
			
		||||
 | 
			
		||||
    app_config = {'datastore_path': datastore_path, 'disable_checkver' : True}
 | 
			
		||||
    app_config = {'datastore_path': datastore_path}
 | 
			
		||||
    cleanup(app_config['datastore_path'])
 | 
			
		||||
    datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], include_default_watches=False)
 | 
			
		||||
    app = changedetection_app(app_config, datastore)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,2 +0,0 @@
 | 
			
		||||
"""Tests for the app."""
 | 
			
		||||
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
#!/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])
 | 
			
		||||
@@ -1,10 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "proxy-one": {
 | 
			
		||||
    "label": "One",
 | 
			
		||||
    "url": "http://127.0.0.1:3128"
 | 
			
		||||
  },
 | 
			
		||||
  "proxy-two": {
 | 
			
		||||
    "label": "two",
 | 
			
		||||
    "url": "http://127.0.0.1:3129"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,41 +0,0 @@
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
@@ -1,38 +0,0 @@
 | 
			
		||||
#!/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
 | 
			
		||||
@@ -1,19 +0,0 @@
 | 
			
		||||
#!/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,16 +147,6 @@ 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),
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from urllib.request import urlopen
 | 
			
		||||
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks
 | 
			
		||||
from .util import set_original_response, set_modified_response, live_server_setup
 | 
			
		||||
 | 
			
		||||
sleep_time_for_fetch_thread = 3
 | 
			
		||||
 | 
			
		||||
@@ -36,7 +36,7 @@ def test_check_basic_change_detection_functionality(client, live_server):
 | 
			
		||||
        client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
        # Give the thread time to pick it up
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
        time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
 | 
			
		||||
        # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
@@ -69,7 +69,7 @@ def test_check_basic_change_detection_functionality(client, live_server):
 | 
			
		||||
    res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    assert b'1 watches are queued for rechecking.' in res.data
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
 | 
			
		||||
    # Now something should be ready, indicated by having a 'unviewed' class
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
@@ -98,14 +98,14 @@ def test_check_basic_change_detection_functionality(client, live_server):
 | 
			
		||||
    assert b'which has this one new line' in res.data
 | 
			
		||||
    assert b'Which is across multiple lines' not in res.data
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(2)
 | 
			
		||||
 | 
			
		||||
    # Do this a few times.. ensures we dont accidently set the status
 | 
			
		||||
    for n in range(2):
 | 
			
		||||
        client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
        # Give the thread time to pick it up
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
        time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
 | 
			
		||||
        # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
@@ -125,7 +125,7 @@ def test_check_basic_change_detection_functionality(client, live_server):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -1,31 +1,18 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
 | 
			
		||||
from .util import set_original_response, set_modified_response, live_server_setup
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from urllib.request import urlopen
 | 
			
		||||
from zipfile import ZipFile
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
from . util import set_original_response, set_modified_response, live_server_setup
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_backup(client, live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    set_original_response()
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
        data={"urls": url_for('test_endpoint', _external=True)},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("get_backup"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
@@ -33,19 +20,6 @@ def test_backup(client, live_server):
 | 
			
		||||
 | 
			
		||||
    # Should get the right zip content type
 | 
			
		||||
    assert res.content_type == "application/zip"
 | 
			
		||||
 | 
			
		||||
    # Should be PK/ZIP stream
 | 
			
		||||
    assert res.data.count(b'PK') >= 2
 | 
			
		||||
 | 
			
		||||
    # ZipFile from buffer seems non-obvious, just save it instead
 | 
			
		||||
    with open("download.zip", 'wb') as f:
 | 
			
		||||
        f.write(res.data)
 | 
			
		||||
 | 
			
		||||
    zip = ZipFile('download.zip')
 | 
			
		||||
    l = zip.namelist()
 | 
			
		||||
    uuid4hex = re.compile('^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}.*txt', re.I)
 | 
			
		||||
    newlist = list(filter(uuid4hex.match, l))  # Read Note below
 | 
			
		||||
 | 
			
		||||
    # Should be two txt files in the archive (history and the snapshot)
 | 
			
		||||
    assert len(newlist) == 2
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,33 +0,0 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import live_server_setup
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# If there was only a change in the whitespacing, then we shouldnt have a change detected
 | 
			
		||||
def test_jinja2_in_url_query(client, live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    test_url = url_for('test_return_query', _external=True)
 | 
			
		||||
 | 
			
		||||
    # because url_for() will URL-encode the var, but we dont here
 | 
			
		||||
    full_url = "{}?{}".format(test_url,
 | 
			
		||||
                              "date={% now 'Europe/Berlin', '%Y' %}.{% now 'Europe/Berlin', '%m' %}.{% now 'Europe/Berlin', '%d' %}", )
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("form_quick_watch_add"),
 | 
			
		||||
        data={"url": full_url, "tag": "test"},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Watch added" in res.data
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("preview_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b'date=2' in res.data
 | 
			
		||||
@@ -2,15 +2,10 @@
 | 
			
		||||
# coding=utf-8
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for, escape
 | 
			
		||||
from flask import url_for
 | 
			
		||||
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)
 | 
			
		||||
@@ -41,28 +36,16 @@ 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, "json:$.offers.price")
 | 
			
		||||
    text = html_tools.extract_json_as_string(content, "$.offers.price")
 | 
			
		||||
    assert text == "23.5"
 | 
			
		||||
 | 
			
		||||
    # 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")
 | 
			
		||||
    text = html_tools.extract_json_as_string('{"id":5}', "$.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!', "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")
 | 
			
		||||
        html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', "$.id")
 | 
			
		||||
 | 
			
		||||
def set_original_ext_response():
 | 
			
		||||
    data = """
 | 
			
		||||
@@ -83,7 +66,6 @@ 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 = """
 | 
			
		||||
@@ -104,7 +86,6 @@ 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 = """
 | 
			
		||||
@@ -203,10 +184,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 check_json_filter(json_filter, client, live_server):
 | 
			
		||||
def test_check_json_filter(client, live_server):
 | 
			
		||||
    json_filter = 'json:boss.name'
 | 
			
		||||
 | 
			
		||||
    set_original_response()
 | 
			
		||||
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
@@ -245,7 +226,7 @@ def check_json_filter(json_filter, client, live_server):
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
    )
 | 
			
		||||
    assert bytes(escape(json_filter).encode('utf-8')) in res.data
 | 
			
		||||
    assert bytes(json_filter.encode('utf-8')) in res.data
 | 
			
		||||
 | 
			
		||||
    # Trigger a check
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
@@ -271,17 +252,10 @@ def check_json_filter(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_jsonpath_filter(client, live_server):
 | 
			
		||||
    check_json_filter('json:boss.name', client, live_server)
 | 
			
		||||
def test_check_json_filter_bool_val(client, live_server):
 | 
			
		||||
    json_filter = "json:$['available']"
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
@@ -330,22 +304,14 @@ def check_json_filter_bool_val(json_filter, 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 check_json_ext_filter(json_filter, client, live_server):
 | 
			
		||||
def test_check_json_ext_filter(client, live_server):
 | 
			
		||||
    json_filter = 'json:$[?(@.status==Sold)]'
 | 
			
		||||
 | 
			
		||||
    set_original_ext_response()
 | 
			
		||||
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
@@ -384,7 +350,7 @@ def check_json_ext_filter(json_filter, client, live_server):
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
    )
 | 
			
		||||
    assert bytes(escape(json_filter).encode('utf-8')) in res.data
 | 
			
		||||
    assert bytes(json_filter.encode('utf-8')) in res.data
 | 
			
		||||
 | 
			
		||||
    # Trigger a check
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
@@ -410,12 +376,3 @@ def check_json_ext_filter(json_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,13 +4,7 @@ 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_format,
 | 
			
		||||
    default_notification_title,
 | 
			
		||||
    valid_notification_formats,
 | 
			
		||||
)
 | 
			
		||||
from changedetectionio.notification import default_notification_body, default_notification_title
 | 
			
		||||
 | 
			
		||||
def test_setup(live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
@@ -26,26 +20,9 @@ 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):
 | 
			
		||||
@@ -70,6 +47,8 @@ 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)
 | 
			
		||||
 | 
			
		||||
@@ -179,30 +158,6 @@ 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"),
 | 
			
		||||
@@ -225,20 +180,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(
 | 
			
		||||
@@ -260,5 +215,3 @@ def test_notification_validation(client, live_server):
 | 
			
		||||
        url_for("form_delete", uuid="all"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -86,7 +86,6 @@ def extract_UUID_from_client(client):
 | 
			
		||||
def wait_for_all_checks(client):
 | 
			
		||||
    # Loop waiting until done..
 | 
			
		||||
    attempt=0
 | 
			
		||||
    time.sleep(0.1)
 | 
			
		||||
    while attempt < 60:
 | 
			
		||||
        time.sleep(1)
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
@@ -160,10 +159,5 @@ def live_server_setup(live_server):
 | 
			
		||||
        ret = " ".join([auth.username, auth.password, auth.type])
 | 
			
		||||
        return ret
 | 
			
		||||
 | 
			
		||||
    # Just return some GET var
 | 
			
		||||
    @live_server.app.route('/test-return-query', methods=['GET'])
 | 
			
		||||
    def test_return_query():
 | 
			
		||||
        return request.query_string
 | 
			
		||||
 | 
			
		||||
    live_server.start()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -7,15 +7,14 @@ 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, 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"
 | 
			
		||||
 | 
			
		||||
    # 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'
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("form_quick_watch_add"),
 | 
			
		||||
        data={"url": test_url, "tag": '', 'edit_and_watch_submit_button': 'Edit > Watch'},
 | 
			
		||||
@@ -25,30 +24,12 @@ def test_visual_selector_content_ready(client, live_server):
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first", unpause_on_save=1),
 | 
			
		||||
        data={
 | 
			
		||||
              "url": test_url,
 | 
			
		||||
              "tag": "",
 | 
			
		||||
              "headers": "",
 | 
			
		||||
              'fetch_backend': "html_webdriver",
 | 
			
		||||
              'webdriver_js_execute_code': 'document.querySelector("button[name=test-button]").click();'
 | 
			
		||||
        },
 | 
			
		||||
        data={"css_filter": ".does-not-exist", "url": test_url, "tag": "", "headers": "", 'fetch_backend': "html_webdriver"},
 | 
			
		||||
        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,14 +11,11 @@ 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
 | 
			
		||||
@@ -29,10 +26,6 @@ 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:
 | 
			
		||||
@@ -47,27 +40,33 @@ 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?"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        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']
 | 
			
		||||
        # 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']
 | 
			
		||||
 | 
			
		||||
        # 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 and n_object['notification_urls']:
 | 
			
		||||
        if 'notification_urls' in n_object:
 | 
			
		||||
            # 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()
 | 
			
		||||
 | 
			
		||||
@@ -78,10 +77,8 @@ 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):
 | 
			
		||||
 | 
			
		||||
@@ -186,9 +183,6 @@ 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,8 +6,6 @@ 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
 | 
			
		||||
@@ -32,7 +30,7 @@ services:
 | 
			
		||||
  #
 | 
			
		||||
  #             https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch-option-proxy
 | 
			
		||||
  #
 | 
			
		||||
  #        Plain requests - proxy support example.
 | 
			
		||||
  #        Plain requsts - proxy support example.
 | 
			
		||||
  #      - HTTP_PROXY=socks5h://10.10.1.10:1080
 | 
			
		||||
  #      - HTTPS_PROXY=socks5h://10.10.1.10:1080
 | 
			
		||||
  #
 | 
			
		||||
@@ -45,9 +43,6 @@ 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:
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 46 KiB  | 
@@ -1,36 +1,31 @@
 | 
			
		||||
flask~=2.0
 | 
			
		||||
flask~= 2.0
 | 
			
		||||
flask_wtf
 | 
			
		||||
eventlet>=0.31.0
 | 
			
		||||
validators
 | 
			
		||||
timeago~=1.0
 | 
			
		||||
inscriptis~=2.2
 | 
			
		||||
feedgen~=0.9
 | 
			
		||||
flask-login~=0.5
 | 
			
		||||
timeago ~=1.0
 | 
			
		||||
inscriptis ~= 2.2
 | 
			
		||||
feedgen ~= 0.9
 | 
			
		||||
flask-login ~= 0.5
 | 
			
		||||
flask_restful
 | 
			
		||||
pytz
 | 
			
		||||
 | 
			
		||||
# Set these versions together to avoid a RequestsDependencyWarning
 | 
			
		||||
# >= 2.26 also adds Brotli support if brotli is installed
 | 
			
		||||
brotli~=1.0
 | 
			
		||||
requests[socks] ~=2.28
 | 
			
		||||
requests[socks] ~= 2.26
 | 
			
		||||
urllib3 > 1.26
 | 
			
		||||
chardet > 2.3.0
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
wtforms ~= 3.0
 | 
			
		||||
jsonpath-ng ~= 1.5.3
 | 
			
		||||
 | 
			
		||||
# Notification library
 | 
			
		||||
apprise~=1.1.0
 | 
			
		||||
apprise ~= 1.0.0
 | 
			
		||||
 | 
			
		||||
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
 | 
			
		||||
paho-mqtt
 | 
			
		||||
 | 
			
		||||
# Pinned version of cryptography otherwise
 | 
			
		||||
# ERROR: Could not build wheels for cryptography which use PEP 517 and cannot be installed directly
 | 
			
		||||
cryptography~=3.4
 | 
			
		||||
cryptography ~= 3.4
 | 
			
		||||
 | 
			
		||||
# Used for CSS filtering
 | 
			
		||||
bs4
 | 
			
		||||
@@ -39,20 +34,11 @@ bs4
 | 
			
		||||
lxml
 | 
			
		||||
 | 
			
		||||
# 3.141 was missing socksVersion, 3.150 was not in pypi, so we try 4.1.0
 | 
			
		||||
selenium~=4.1.0
 | 
			
		||||
selenium ~= 4.1.0
 | 
			
		||||
 | 
			
		||||
# https://stackoverflow.com/questions/71652965/importerror-cannot-import-name-safe-str-cmp-from-werkzeug-security/71653849#71653849
 | 
			
		||||
# ImportError: cannot import name 'safe_str_cmp' from 'werkzeug.security'
 | 
			
		||||
# need to revisit flask login versions
 | 
			
		||||
werkzeug~=2.0.0
 | 
			
		||||
 | 
			
		||||
# Templating, so far just in the URLs but in the future can be for the notifications also
 | 
			
		||||
jinja2~=3.1
 | 
			
		||||
jinja2-time
 | 
			
		||||
 | 
			
		||||
# https://peps.python.org/pep-0508/#environment-markers
 | 
			
		||||
# https://github.com/dgtlmoon/changedetection.io/pull/1009
 | 
			
		||||
jq ~= 1.3 ;python_version >= "3.8" and sys_platform == "linux"
 | 
			
		||||
playwright~=1.26; python_version >= "3.8" and "arm" not in platform_machine and "aarch" not in platform_machine
 | 
			
		||||
 | 
			
		||||
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