mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-11-03 08:07:23 +00:00 
			
		
		
		
	Compare commits
	
		
			33 Commits
		
	
	
		
			0.48.03
			...
			UI-tabs-fi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					74799cd840 | ||
| 
						 | 
					467f055b67 | ||
| 
						 | 
					82211eef82 | ||
| 
						 | 
					5d9380609c | ||
| 
						 | 
					a8b3918fca | ||
| 
						 | 
					e83fb37fb6 | ||
| 
						 | 
					6b99afe0f7 | ||
| 
						 | 
					09ebc6ec63 | ||
| 
						 | 
					6b1065502e | ||
| 
						 | 
					d4c470984a | ||
| 
						 | 
					55da48f719 | ||
| 
						 | 
					dbd4adf23a | ||
| 
						 | 
					b1e700b3ff | ||
| 
						 | 
					1c61b5a623 | ||
| 
						 | 
					e799a1cdcb | ||
| 
						 | 
					938065db6f | ||
| 
						 | 
					4f2d38ff49 | ||
| 
						 | 
					8960f401b7 | ||
| 
						 | 
					1c1f1c6f6b | ||
| 
						 | 
					a2a98811a5 | ||
| 
						 | 
					5a0ef8fc01 | ||
| 
						 | 
					d90de0851d | ||
| 
						 | 
					360b4f0d8b | ||
| 
						 | 
					6fc04d7f1c | ||
| 
						 | 
					66fb05527b | ||
| 
						 | 
					202e47d728 | ||
| 
						 | 
					d67d396b88 | ||
| 
						 | 
					05f54f0ce6 | ||
| 
						 | 
					6adf10597e | ||
| 
						 | 
					4419bc0e61 | ||
| 
						 | 
					f7e9846c9b | ||
| 
						 | 
					5dea5e1def | ||
| 
						 | 
					0fade0a473 | 
							
								
								
									
										23
									
								
								.github/test/Dockerfile-alpine
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								.github/test/Dockerfile-alpine
									
									
									
									
										vendored
									
									
								
							@@ -2,32 +2,33 @@
 | 
			
		||||
# Test that we can still build on Alpine (musl modified libc https://musl.libc.org/)
 | 
			
		||||
# Some packages wont install via pypi because they dont have a wheel available under this architecture.
 | 
			
		||||
 | 
			
		||||
FROM ghcr.io/linuxserver/baseimage-alpine:3.18
 | 
			
		||||
FROM ghcr.io/linuxserver/baseimage-alpine:3.21
 | 
			
		||||
ENV PYTHONUNBUFFERED=1
 | 
			
		||||
 | 
			
		||||
COPY requirements.txt /requirements.txt
 | 
			
		||||
 | 
			
		||||
RUN \
 | 
			
		||||
  apk add --update --no-cache --virtual=build-dependencies \
 | 
			
		||||
 apk add --update --no-cache --virtual=build-dependencies \
 | 
			
		||||
    build-base \
 | 
			
		||||
    cargo \
 | 
			
		||||
    g++ \
 | 
			
		||||
    gcc \
 | 
			
		||||
    git \
 | 
			
		||||
    jpeg-dev \
 | 
			
		||||
    libc-dev \
 | 
			
		||||
    libffi-dev \
 | 
			
		||||
    libjpeg \
 | 
			
		||||
    libxslt-dev \
 | 
			
		||||
    make \
 | 
			
		||||
    openssl-dev \
 | 
			
		||||
    py3-wheel \
 | 
			
		||||
    python3-dev \
 | 
			
		||||
    zip \
 | 
			
		||||
    zlib-dev && \
 | 
			
		||||
  apk add --update --no-cache \
 | 
			
		||||
    libjpeg \
 | 
			
		||||
    libxslt \
 | 
			
		||||
    python3 \
 | 
			
		||||
    py3-pip && \
 | 
			
		||||
    nodejs \
 | 
			
		||||
    poppler-utils \
 | 
			
		||||
    python3 && \
 | 
			
		||||
  echo "**** pip3 install test of changedetection.io ****" && \
 | 
			
		||||
  pip3 install -U pip wheel setuptools && \
 | 
			
		||||
  pip3 install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/alpine-3.18/ -r /requirements.txt && \
 | 
			
		||||
  python3 -m venv /lsiopy  && \
 | 
			
		||||
  pip install -U pip wheel setuptools && \
 | 
			
		||||
  pip install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/alpine-3.21/ -r /requirements.txt && \
 | 
			
		||||
  apk del --purge \
 | 
			
		||||
    build-dependencies
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										19
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							@@ -103,6 +103,19 @@ jobs:
 | 
			
		||||
#          provenance: false
 | 
			
		||||
 | 
			
		||||
      # A new tagged release is required, which builds :tag and :latest
 | 
			
		||||
      - name: Docker meta :tag
 | 
			
		||||
        if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
 | 
			
		||||
        uses: docker/metadata-action@v5
 | 
			
		||||
        id: meta
 | 
			
		||||
        with:
 | 
			
		||||
            images: |
 | 
			
		||||
                ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io
 | 
			
		||||
                ghcr.io/dgtlmoon/changedetection.io
 | 
			
		||||
            tags: |
 | 
			
		||||
                type=semver,pattern={{version}}
 | 
			
		||||
                type=semver,pattern={{major}}.{{minor}}
 | 
			
		||||
                type=semver,pattern={{major}}
 | 
			
		||||
 | 
			
		||||
      - name: Build and push :tag
 | 
			
		||||
        id: docker_build_tag_release
 | 
			
		||||
        if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
 | 
			
		||||
@@ -111,11 +124,7 @@ jobs:
 | 
			
		||||
          context: ./
 | 
			
		||||
          file: ./Dockerfile
 | 
			
		||||
          push: true
 | 
			
		||||
          tags: |
 | 
			
		||||
            ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:${{ github.event.release.tag_name }}
 | 
			
		||||
            ghcr.io/dgtlmoon/changedetection.io:${{ github.event.release.tag_name }}
 | 
			
		||||
            ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:latest
 | 
			
		||||
            ghcr.io/dgtlmoon/changedetection.io:latest
 | 
			
		||||
          tags: ${{ steps.meta.outputs.tags }}
 | 
			
		||||
          platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
 | 
			
		||||
          cache-from: type=gha
 | 
			
		||||
          cache-to: type=gha,mode=max
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.github/workflows/pypi-release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/pypi-release.yml
									
									
									
									
										vendored
									
									
								
							@@ -45,7 +45,6 @@ jobs:
 | 
			
		||||
    - name: Test that the basic pip built package runs without error
 | 
			
		||||
      run: |
 | 
			
		||||
        set -ex
 | 
			
		||||
        sudo pip3 install --upgrade pip 
 | 
			
		||||
        pip3 install dist/changedetection.io*.whl
 | 
			
		||||
        changedetection.io -d /tmp -p 10000 &
 | 
			
		||||
        sleep 3
 | 
			
		||||
 
 | 
			
		||||
@@ -64,14 +64,16 @@ jobs:
 | 
			
		||||
          echo "Running processes in docker..."
 | 
			
		||||
          docker ps
 | 
			
		||||
 | 
			
		||||
      - name: Test built container with Pytest (generally as requests/plaintext fetching)
 | 
			
		||||
      - name: Run Unit Tests
 | 
			
		||||
        run: |
 | 
			
		||||
          # Unit tests
 | 
			
		||||
          echo "run test with unittest"
 | 
			
		||||
          docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff'
 | 
			
		||||
          docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
 | 
			
		||||
          docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
 | 
			
		||||
          
 | 
			
		||||
          docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'
 | 
			
		||||
 | 
			
		||||
      - name: Test built container with Pytest (generally as requests/plaintext fetching)
 | 
			
		||||
        run: |
 | 
			
		||||
          # All tests
 | 
			
		||||
          echo "run test with pytest"
 | 
			
		||||
          # The default pytest logger_level is TRACE
 | 
			
		||||
 
 | 
			
		||||
@@ -120,7 +120,7 @@ Easily add the current web page to your changedetection.io tool, simply install
 | 
			
		||||
 | 
			
		||||
[<img src="./docs/chrome-extension-screenshot.png" style="max-width:80%;" alt="Chrome Extension to easily add the current web-page to detect a change."  title="Chrome Extension to easily add the current web-page to detect a change."  />](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop)
 | 
			
		||||
 | 
			
		||||
[Goto the Chrome Webstore to download the extension.](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop)
 | 
			
		||||
[Goto the Chrome Webstore to download the extension.](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop) ( Or check out the [GitHub repo](https://github.com/dgtlmoon/changedetection.io-browser-extension) ) 
 | 
			
		||||
 | 
			
		||||
## Installation
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
 | 
			
		||||
 | 
			
		||||
__version__ = '0.48.03'
 | 
			
		||||
__version__ = '0.49.1'
 | 
			
		||||
 | 
			
		||||
from changedetectionio.strtobool import strtobool
 | 
			
		||||
from json.decoder import JSONDecodeError
 | 
			
		||||
@@ -24,6 +24,9 @@ from loguru import logger
 | 
			
		||||
app = None
 | 
			
		||||
datastore = None
 | 
			
		||||
 | 
			
		||||
def get_version():
 | 
			
		||||
    return __version__
 | 
			
		||||
 | 
			
		||||
# Parent wrapper or OS sends us a SIGTERM/SIGINT, do everything required for a clean shutdown
 | 
			
		||||
def sigshutdown_handler(_signo, _stack_frame):
 | 
			
		||||
    global app
 | 
			
		||||
 
 | 
			
		||||
@@ -112,6 +112,35 @@ def build_watch_json_schema(d):
 | 
			
		||||
 | 
			
		||||
    schema['properties']['time_between_check'] = build_time_between_check_json_schema()
 | 
			
		||||
 | 
			
		||||
    schema['properties']['browser_steps'] = {
 | 
			
		||||
        "anyOf": [
 | 
			
		||||
            {
 | 
			
		||||
                "type": "array",
 | 
			
		||||
                "items": {
 | 
			
		||||
                    "type": "object",
 | 
			
		||||
                    "properties": {
 | 
			
		||||
                        "operation": {
 | 
			
		||||
                            "type": ["string", "null"],
 | 
			
		||||
                            "maxLength": 5000  # Allows null and any string up to 5000 chars (including "")
 | 
			
		||||
                        },
 | 
			
		||||
                        "selector": {
 | 
			
		||||
                            "type": ["string", "null"],
 | 
			
		||||
                            "maxLength": 5000
 | 
			
		||||
                        },
 | 
			
		||||
                        "optional_value": {
 | 
			
		||||
                            "type": ["string", "null"],
 | 
			
		||||
                            "maxLength": 5000
 | 
			
		||||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                    "required": ["operation", "selector", "optional_value"],
 | 
			
		||||
                    "additionalProperties": False  # No extra keys allowed
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            {"type": "null"},  # Allows null for `browser_steps`
 | 
			
		||||
            {"type": "array", "maxItems": 0}  # Allows empty array []
 | 
			
		||||
        ]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # headers ?
 | 
			
		||||
    return schema
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -76,6 +76,7 @@ class Watch(Resource):
 | 
			
		||||
        # Return without history, get that via another API call
 | 
			
		||||
        # Properties are not returned as a JSON, so add the required props manually
 | 
			
		||||
        watch['history_n'] = watch.history_n
 | 
			
		||||
        # attr .last_changed will check for the last written text snapshot on change
 | 
			
		||||
        watch['last_changed'] = watch.last_changed
 | 
			
		||||
        watch['viewed'] = watch.viewed
 | 
			
		||||
        return watch
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
from changedetectionio import apprise_plugin
 | 
			
		||||
import apprise
 | 
			
		||||
 | 
			
		||||
# Create our AppriseAsset and populate it with some of our new values:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,8 @@
 | 
			
		||||
# include the decorator
 | 
			
		||||
from apprise.decorators import notify
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from requests.structures import CaseInsensitiveDict
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@notify(on="delete")
 | 
			
		||||
@notify(on="deletes")
 | 
			
		||||
@@ -13,70 +15,84 @@ from loguru import logger
 | 
			
		||||
def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
 | 
			
		||||
    import requests
 | 
			
		||||
    import json
 | 
			
		||||
    import re
 | 
			
		||||
 | 
			
		||||
    from urllib.parse import unquote_plus
 | 
			
		||||
    from apprise.utils import parse_url as apprise_parse_url
 | 
			
		||||
    from apprise import URLBase
 | 
			
		||||
    from apprise.utils.parse import parse_url as apprise_parse_url
 | 
			
		||||
 | 
			
		||||
    url = kwargs['meta'].get('url')
 | 
			
		||||
    schema = kwargs['meta'].get('schema').lower().strip()
 | 
			
		||||
 | 
			
		||||
    if url.startswith('post'):
 | 
			
		||||
        r = requests.post
 | 
			
		||||
    elif url.startswith('get'):
 | 
			
		||||
        r = requests.get
 | 
			
		||||
    elif url.startswith('put'):
 | 
			
		||||
        r = requests.put
 | 
			
		||||
    elif url.startswith('delete'):
 | 
			
		||||
        r = requests.delete
 | 
			
		||||
    # Choose POST, GET etc from requests
 | 
			
		||||
    method =  re.sub(rf's$', '', schema)
 | 
			
		||||
    requests_method = getattr(requests, method)
 | 
			
		||||
 | 
			
		||||
    url = url.replace('post://', 'http://')
 | 
			
		||||
    url = url.replace('posts://', 'https://')
 | 
			
		||||
    url = url.replace('put://', 'http://')
 | 
			
		||||
    url = url.replace('puts://', 'https://')
 | 
			
		||||
    url = url.replace('get://', 'http://')
 | 
			
		||||
    url = url.replace('gets://', 'https://')
 | 
			
		||||
    url = url.replace('put://', 'http://')
 | 
			
		||||
    url = url.replace('puts://', 'https://')
 | 
			
		||||
    url = url.replace('delete://', 'http://')
 | 
			
		||||
    url = url.replace('deletes://', 'https://')
 | 
			
		||||
 | 
			
		||||
    headers = {}
 | 
			
		||||
    params = {}
 | 
			
		||||
    params = CaseInsensitiveDict({}) # Added to requests
 | 
			
		||||
    auth = None
 | 
			
		||||
    has_error = False
 | 
			
		||||
 | 
			
		||||
    # Convert /foobar?+some-header=hello to proper header dictionary
 | 
			
		||||
    results = apprise_parse_url(url)
 | 
			
		||||
    if results:
 | 
			
		||||
        # Add our headers that the user can potentially over-ride if they wish
 | 
			
		||||
        # to to our returned result set and tidy entries by unquoting them
 | 
			
		||||
        headers = {unquote_plus(x): unquote_plus(y)
 | 
			
		||||
                   for x, y in results['qsd+'].items()}
 | 
			
		||||
 | 
			
		||||
        # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
 | 
			
		||||
        # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise
 | 
			
		||||
        # but here we are making straight requests, so we need todo convert this against apprise's logic
 | 
			
		||||
        for k, v in results['qsd'].items():
 | 
			
		||||
            if not k.strip('+-') in results['qsd+'].keys():
 | 
			
		||||
                params[unquote_plus(k)] = unquote_plus(v)
 | 
			
		||||
    # Add our headers that the user can potentially over-ride if they wish
 | 
			
		||||
    # to to our returned result set and tidy entries by unquoting them
 | 
			
		||||
    headers = CaseInsensitiveDict({unquote_plus(x): unquote_plus(y)
 | 
			
		||||
               for x, y in results['qsd+'].items()})
 | 
			
		||||
 | 
			
		||||
        # Determine Authentication
 | 
			
		||||
        auth = ''
 | 
			
		||||
        if results.get('user') and results.get('password'):
 | 
			
		||||
            auth = (unquote_plus(results.get('user')), unquote_plus(results.get('user')))
 | 
			
		||||
        elif results.get('user'):
 | 
			
		||||
            auth = (unquote_plus(results.get('user')))
 | 
			
		||||
    # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
 | 
			
		||||
    # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise
 | 
			
		||||
    # but here we are making straight requests, so we need todo convert this against apprise's logic
 | 
			
		||||
    for k, v in results['qsd'].items():
 | 
			
		||||
        if not k.strip('+-') in results['qsd+'].keys():
 | 
			
		||||
            params[unquote_plus(k)] = unquote_plus(v)
 | 
			
		||||
 | 
			
		||||
    # Try to auto-guess if it's JSON
 | 
			
		||||
    h = 'application/json; charset=utf-8'
 | 
			
		||||
    # Determine Authentication
 | 
			
		||||
    auth = ''
 | 
			
		||||
    if results.get('user') and results.get('password'):
 | 
			
		||||
        auth = (unquote_plus(results.get('user')), unquote_plus(results.get('user')))
 | 
			
		||||
    elif results.get('user'):
 | 
			
		||||
        auth = (unquote_plus(results.get('user')))
 | 
			
		||||
 | 
			
		||||
    # If it smells like it could be JSON and no content-type was already set, offer a default content type.
 | 
			
		||||
    if body and '{' in body[:100] and not headers.get('Content-Type'):
 | 
			
		||||
        json_header = 'application/json; charset=utf-8'
 | 
			
		||||
        try:
 | 
			
		||||
            # Try if it's JSON
 | 
			
		||||
            json.loads(body)
 | 
			
		||||
            headers['Content-Type'] = json_header
 | 
			
		||||
        except ValueError as e:
 | 
			
		||||
            logger.warning(f"Could not automatically add '{json_header}' header to the notification because the document failed to parse as JSON: {e}")
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    # POSTS -> HTTPS etc
 | 
			
		||||
    if schema.lower().endswith('s'):
 | 
			
		||||
        url = re.sub(rf'^{schema}', 'https', results.get('url'))
 | 
			
		||||
    else:
 | 
			
		||||
        url = re.sub(rf'^{schema}', 'http', results.get('url'))
 | 
			
		||||
 | 
			
		||||
    status_str = ''
 | 
			
		||||
    try:
 | 
			
		||||
        json.loads(body)
 | 
			
		||||
        headers['Content-Type'] = h
 | 
			
		||||
    except ValueError as e:
 | 
			
		||||
        logger.warning(f"Could not automatically add '{h}' header to the {kwargs['meta'].get('schema')}:// notification because the document failed to parse as JSON: {e}")
 | 
			
		||||
        pass
 | 
			
		||||
        r = requests_method(url,
 | 
			
		||||
          auth=auth,
 | 
			
		||||
          data=body.encode('utf-8') if type(body) is str else body,
 | 
			
		||||
          headers=headers,
 | 
			
		||||
          params=params
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    r(results.get('url'),
 | 
			
		||||
      auth=auth,
 | 
			
		||||
      data=body.encode('utf-8') if type(body) is str else body,
 | 
			
		||||
      headers=headers,
 | 
			
		||||
      params=params
 | 
			
		||||
      )
 | 
			
		||||
        if not (200 <= r.status_code < 300):
 | 
			
		||||
            status_str = f"Error sending '{method.upper()}' request to {url} - Status: {r.status_code}: '{r.reason}'"
 | 
			
		||||
            logger.error(status_str)
 | 
			
		||||
            has_error = True
 | 
			
		||||
        else:
 | 
			
		||||
            logger.info(f"Sent '{method.upper()}' request to {url}")
 | 
			
		||||
            has_error = False
 | 
			
		||||
 | 
			
		||||
    except requests.RequestException as e:
 | 
			
		||||
        status_str = f"Error sending '{method.upper()}' request to {url} - {str(e)}"
 | 
			
		||||
        logger.error(status_str)
 | 
			
		||||
        has_error = True
 | 
			
		||||
 | 
			
		||||
    if has_error:
 | 
			
		||||
        raise TypeError(status_str)
 | 
			
		||||
 | 
			
		||||
    return True
 | 
			
		||||
 
 | 
			
		||||
@@ -52,6 +52,8 @@ class steppable_browser_interface():
 | 
			
		||||
    page = None
 | 
			
		||||
    start_url = None
 | 
			
		||||
 | 
			
		||||
    action_timeout = 10 * 1000
 | 
			
		||||
 | 
			
		||||
    def __init__(self, start_url):
 | 
			
		||||
        self.start_url = start_url
 | 
			
		||||
 | 
			
		||||
@@ -102,7 +104,7 @@ class steppable_browser_interface():
 | 
			
		||||
            return
 | 
			
		||||
        elem = self.page.get_by_text(value)
 | 
			
		||||
        if elem.count():
 | 
			
		||||
            elem.first.click(delay=randint(200, 500), timeout=3000)
 | 
			
		||||
            elem.first.click(delay=randint(200, 500), timeout=self.action_timeout)
 | 
			
		||||
 | 
			
		||||
    def action_click_element_containing_text_if_exists(self, selector=None, value=''):
 | 
			
		||||
        logger.debug("Clicking element containing text if exists")
 | 
			
		||||
@@ -111,7 +113,7 @@ class steppable_browser_interface():
 | 
			
		||||
        elem = self.page.get_by_text(value)
 | 
			
		||||
        logger.debug(f"Clicking element containing text - {elem.count()} elements found")
 | 
			
		||||
        if elem.count():
 | 
			
		||||
            elem.first.click(delay=randint(200, 500), timeout=3000)
 | 
			
		||||
            elem.first.click(delay=randint(200, 500), timeout=self.action_timeout)
 | 
			
		||||
        else:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
@@ -119,7 +121,7 @@ class steppable_browser_interface():
 | 
			
		||||
        if not len(selector.strip()):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self.page.fill(selector, value, timeout=10 * 1000)
 | 
			
		||||
        self.page.fill(selector, value, timeout=self.action_timeout)
 | 
			
		||||
 | 
			
		||||
    def action_execute_js(self, selector, value):
 | 
			
		||||
        response = self.page.evaluate(value)
 | 
			
		||||
@@ -130,7 +132,7 @@ class steppable_browser_interface():
 | 
			
		||||
        if not len(selector.strip()):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self.page.click(selector=selector, timeout=30 * 1000, delay=randint(200, 500))
 | 
			
		||||
        self.page.click(selector=selector, timeout=self.action_timeout + 20 * 1000, delay=randint(200, 500))
 | 
			
		||||
 | 
			
		||||
    def action_click_element_if_exists(self, selector, value):
 | 
			
		||||
        import playwright._impl._errors as _api_types
 | 
			
		||||
@@ -138,7 +140,7 @@ class steppable_browser_interface():
 | 
			
		||||
        if not len(selector.strip()):
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            self.page.click(selector, timeout=10 * 1000, delay=randint(200, 500))
 | 
			
		||||
            self.page.click(selector, timeout=self.action_timeout, delay=randint(200, 500))
 | 
			
		||||
        except _api_types.TimeoutError as e:
 | 
			
		||||
            return
 | 
			
		||||
        except _api_types.Error as e:
 | 
			
		||||
@@ -185,10 +187,10 @@ class steppable_browser_interface():
 | 
			
		||||
        self.page.keyboard.press("PageDown", delay=randint(200, 500))
 | 
			
		||||
 | 
			
		||||
    def action_check_checkbox(self, selector, value):
 | 
			
		||||
        self.page.locator(selector).check(timeout=1000)
 | 
			
		||||
        self.page.locator(selector).check(timeout=self.action_timeout)
 | 
			
		||||
 | 
			
		||||
    def action_uncheck_checkbox(self, selector, value):
 | 
			
		||||
        self.page.locator(selector, timeout=1000).uncheck(timeout=1000)
 | 
			
		||||
        self.page.locator(selector).uncheck(timeout=self.action_timeout)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Responsible for maintaining a live 'context' with the chrome CDP
 | 
			
		||||
 
 | 
			
		||||
@@ -52,6 +52,7 @@ function isItemInStock() {
 | 
			
		||||
        'niet leverbaar',
 | 
			
		||||
        'niet op voorraad',
 | 
			
		||||
        'no disponible',
 | 
			
		||||
        'non disponibile',
 | 
			
		||||
        'no longer in stock',
 | 
			
		||||
        'no tickets available',
 | 
			
		||||
        'not available',
 | 
			
		||||
 
 | 
			
		||||
@@ -598,17 +598,31 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
            if 'notification_title' in request.form and request.form['notification_title'].strip():
 | 
			
		||||
                n_object['notification_title'] = request.form.get('notification_title', '').strip()
 | 
			
		||||
            elif datastore.data['settings']['application'].get('notification_title'):
 | 
			
		||||
                n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title')
 | 
			
		||||
            else:
 | 
			
		||||
                n_object['notification_title'] = "Test title"
 | 
			
		||||
 | 
			
		||||
            if 'notification_body' in request.form and request.form['notification_body'].strip():
 | 
			
		||||
                n_object['notification_body'] = request.form.get('notification_body', '').strip()
 | 
			
		||||
            elif datastore.data['settings']['application'].get('notification_body'):
 | 
			
		||||
                n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')
 | 
			
		||||
            else:
 | 
			
		||||
                n_object['notification_body'] = "Test body"
 | 
			
		||||
 | 
			
		||||
            n_object['as_async'] = False
 | 
			
		||||
            n_object.update(watch.extra_notification_token_values())
 | 
			
		||||
            from .notification import process_notification
 | 
			
		||||
            sent_obj = process_notification(n_object, datastore)
 | 
			
		||||
 | 
			
		||||
            from . import update_worker
 | 
			
		||||
            new_worker = update_worker.update_worker(update_q, notification_q, app, datastore)
 | 
			
		||||
            new_worker.queue_notification_for_watch(notification_q=notification_q, n_object=n_object, watch=watch)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            return make_response(f"Error: str(e)", 400)
 | 
			
		||||
            e_str = str(e)
 | 
			
		||||
            # Remove this text which is not important and floods the container
 | 
			
		||||
            e_str = e_str.replace(
 | 
			
		||||
                "DEBUG - <class 'apprise.decorators.base.CustomNotifyPlugin.instantiate_plugin.<locals>.CustomNotifyPluginWrapper'>",
 | 
			
		||||
                '')
 | 
			
		||||
 | 
			
		||||
            return make_response(e_str, 400)
 | 
			
		||||
 | 
			
		||||
        return 'OK - Sent test notifications'
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
from typing import List
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from lxml import etree
 | 
			
		||||
from typing import List
 | 
			
		||||
import json
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
@@ -298,8 +299,10 @@ def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None
 | 
			
		||||
# https://github.com/dgtlmoon/changedetection.io/pull/2041#issuecomment-1848397161w
 | 
			
		||||
    # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded within HTML tags
 | 
			
		||||
    try:
 | 
			
		||||
        stripped_text_from_html = _parse_json(json.loads(content), json_filter)
 | 
			
		||||
    except json.JSONDecodeError:
 | 
			
		||||
        # .lstrip("\ufeff") strings ByteOrderMark from UTF8 and still lets the UTF work
 | 
			
		||||
        stripped_text_from_html = _parse_json(json.loads(content.lstrip("\ufeff") ), json_filter)
 | 
			
		||||
    except json.JSONDecodeError as e:
 | 
			
		||||
        logger.warning(str(e))
 | 
			
		||||
 | 
			
		||||
        # Foreach <script json></script> blob.. just return the first that matches json_filter
 | 
			
		||||
        # As a last resort, try to parse the whole <body>
 | 
			
		||||
 
 | 
			
		||||
@@ -69,7 +69,7 @@ def parse_headers_from_text_file(filepath):
 | 
			
		||||
        for l in f.readlines():
 | 
			
		||||
            l = l.strip()
 | 
			
		||||
            if not l.startswith('#') and ':' in l:
 | 
			
		||||
                (k, v) = l.split(':')
 | 
			
		||||
                (k, v) = l.split(':', 1)  # Split only on the first colon
 | 
			
		||||
                headers[k.strip()] = v.strip()
 | 
			
		||||
 | 
			
		||||
    return headers
 | 
			
		||||
@@ -247,37 +247,32 @@ class model(watch_base):
 | 
			
		||||
        bump = self.history
 | 
			
		||||
        return self.__newest_history_key
 | 
			
		||||
 | 
			
		||||
    # Given an arbitrary timestamp, find the closest next key
 | 
			
		||||
    # For example, last_viewed = 1000 so it should return the next 1001 timestamp
 | 
			
		||||
    #
 | 
			
		||||
    # used for the [diff] button so it can preset a smarter from_version
 | 
			
		||||
    # Given an arbitrary timestamp, find the best history key for the [diff] button so it can preset a smarter from_version
 | 
			
		||||
    @property
 | 
			
		||||
    def get_next_snapshot_key_to_last_viewed(self):
 | 
			
		||||
    def get_from_version_based_on_last_viewed(self):
 | 
			
		||||
 | 
			
		||||
        """Unfortunately for now timestamp is stored as string key"""
 | 
			
		||||
        keys = list(self.history.keys())
 | 
			
		||||
        if not keys:
 | 
			
		||||
            return None
 | 
			
		||||
        if len(keys) == 1:
 | 
			
		||||
            return keys[0]
 | 
			
		||||
 | 
			
		||||
        last_viewed = int(self.get('last_viewed'))
 | 
			
		||||
        prev_k = keys[0]
 | 
			
		||||
        sorted_keys = sorted(keys, key=lambda x: int(x))
 | 
			
		||||
        sorted_keys.reverse()
 | 
			
		||||
 | 
			
		||||
        # When the 'last viewed' timestamp is greater than the newest snapshot, return second last
 | 
			
		||||
        if last_viewed > int(sorted_keys[0]):
 | 
			
		||||
        # When the 'last viewed' timestamp is greater than or equal the newest snapshot, return second newest
 | 
			
		||||
        if last_viewed >= int(sorted_keys[0]):
 | 
			
		||||
            return sorted_keys[1]
 | 
			
		||||
        
 | 
			
		||||
        # When the 'last viewed' timestamp is between snapshots, return the older snapshot
 | 
			
		||||
        for newer, older in list(zip(sorted_keys[0:], sorted_keys[1:])):
 | 
			
		||||
            if last_viewed < int(newer) and last_viewed >= int(older):
 | 
			
		||||
                return older
 | 
			
		||||
 | 
			
		||||
        for k in sorted_keys:
 | 
			
		||||
            if int(k) < last_viewed:
 | 
			
		||||
                if prev_k == sorted_keys[0]:
 | 
			
		||||
                    # Return the second last one so we dont recommend the same version compares itself
 | 
			
		||||
                    return sorted_keys[1]
 | 
			
		||||
 | 
			
		||||
                return prev_k
 | 
			
		||||
            prev_k = k
 | 
			
		||||
 | 
			
		||||
        return keys[0]
 | 
			
		||||
        # When the 'last viewed' timestamp is less than the oldest snapshot, return oldest
 | 
			
		||||
        return sorted_keys[-1]
 | 
			
		||||
 | 
			
		||||
    def get_history_snapshot(self, timestamp):
 | 
			
		||||
        import brotli
 | 
			
		||||
 
 | 
			
		||||
@@ -67,6 +67,10 @@ def process_notification(n_object, datastore):
 | 
			
		||||
 | 
			
		||||
    sent_objs = []
 | 
			
		||||
    from .apprise_asset import asset
 | 
			
		||||
 | 
			
		||||
    if 'as_async' in n_object:
 | 
			
		||||
        asset.async_mode = n_object.get('as_async')
 | 
			
		||||
 | 
			
		||||
    apobj = apprise.Apprise(debug=True, asset=asset)
 | 
			
		||||
 | 
			
		||||
    if not n_object.get('notification_urls'):
 | 
			
		||||
@@ -157,8 +161,6 @@ def process_notification(n_object, datastore):
 | 
			
		||||
            attach=n_object.get('screenshot', None)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Give apprise time to register an error
 | 
			
		||||
        time.sleep(3)
 | 
			
		||||
 | 
			
		||||
        # Returns empty string if nothing found, multi-line string otherwise
 | 
			
		||||
        log_value = logs.getvalue()
 | 
			
		||||
 
 | 
			
		||||
@@ -33,8 +33,8 @@ class difference_detection_processor():
 | 
			
		||||
 | 
			
		||||
        url = self.watch.link
 | 
			
		||||
 | 
			
		||||
        # Protect against file://, file:/ access, check the real "link" without any meta "source:" etc prepended.
 | 
			
		||||
        if re.search(r'^file:/', url.strip(), re.IGNORECASE):
 | 
			
		||||
        # Protect against file:, file:/, file:// access, check the real "link" without any meta "source:" etc prepended.
 | 
			
		||||
        if re.search(r'^file:', url.strip(), re.IGNORECASE):
 | 
			
		||||
            if not strtobool(os.getenv('ALLOW_FILE_URI', 'false')):
 | 
			
		||||
                raise Exception(
 | 
			
		||||
                    "file:// type access is denied for security reasons."
 | 
			
		||||
 
 | 
			
		||||
@@ -1,42 +1,52 @@
 | 
			
		||||
$(document).ready(function() {
 | 
			
		||||
$(document).ready(function () {
 | 
			
		||||
 | 
			
		||||
  $('#add-email-helper').click(function (e) {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    email = prompt("Destination email");
 | 
			
		||||
    if(email) {
 | 
			
		||||
      var n = $(".notification-urls");
 | 
			
		||||
      var p=email_notification_prefix;
 | 
			
		||||
      $(n).val( $.trim( $(n).val() )+"\n"+email_notification_prefix+email );
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  $('#send-test-notification').click(function (e) {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
 | 
			
		||||
    data = {
 | 
			
		||||
      notification_body: $('#notification_body').val(),
 | 
			
		||||
      notification_format: $('#notification_format').val(),
 | 
			
		||||
      notification_title: $('#notification_title').val(),
 | 
			
		||||
      notification_urls: $('.notification-urls').val(),
 | 
			
		||||
      tags: $('#tags').val(),
 | 
			
		||||
      window_url: window.location.href,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    $.ajax({
 | 
			
		||||
      type: "POST",
 | 
			
		||||
      url: notification_base_url,
 | 
			
		||||
      data : data,
 | 
			
		||||
        statusCode: {
 | 
			
		||||
        400: function(data) {
 | 
			
		||||
          // More than likely the CSRF token was lost when the server restarted
 | 
			
		||||
          alert(data.responseText);
 | 
			
		||||
    $('#add-email-helper').click(function (e) {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        email = prompt("Destination email");
 | 
			
		||||
        if (email) {
 | 
			
		||||
            var n = $(".notification-urls");
 | 
			
		||||
            var p = email_notification_prefix;
 | 
			
		||||
            $(n).val($.trim($(n).val()) + "\n" + email_notification_prefix + email);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }).done(function(data){
 | 
			
		||||
      console.log(data);
 | 
			
		||||
      alert(data);
 | 
			
		||||
    })
 | 
			
		||||
  });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $('#send-test-notification').click(function (e) {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
 | 
			
		||||
        data = {
 | 
			
		||||
            notification_body: $('#notification_body').val(),
 | 
			
		||||
            notification_format: $('#notification_format').val(),
 | 
			
		||||
            notification_title: $('#notification_title').val(),
 | 
			
		||||
            notification_urls: $('.notification-urls').val(),
 | 
			
		||||
            tags: $('#tags').val(),
 | 
			
		||||
            window_url: window.location.href,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $('.notifications-wrapper .spinner').fadeIn();
 | 
			
		||||
        $('#notification-test-log').show();
 | 
			
		||||
        $.ajax({
 | 
			
		||||
            type: "POST",
 | 
			
		||||
            url: notification_base_url,
 | 
			
		||||
            data: data,
 | 
			
		||||
            statusCode: {
 | 
			
		||||
                400: function (data) {
 | 
			
		||||
                    $("#notification-test-log>span").text(data.responseText);
 | 
			
		||||
                },
 | 
			
		||||
            }
 | 
			
		||||
        }).done(function (data) {
 | 
			
		||||
            $("#notification-test-log>span").text(data);
 | 
			
		||||
        }).fail(function (jqXHR, textStatus, errorThrown) {
 | 
			
		||||
            // Handle connection refused or other errors
 | 
			
		||||
            if (textStatus === "error" && errorThrown === "") {
 | 
			
		||||
                console.error("Connection refused or server unreachable");
 | 
			
		||||
                $("#notification-test-log>span").text("Error: Connection refused or server is unreachable.");
 | 
			
		||||
            } else {
 | 
			
		||||
                console.error("Error:", textStatus, errorThrown);
 | 
			
		||||
                $("#notification-test-log>span").text("An error occurred: " + textStatus);
 | 
			
		||||
            }
 | 
			
		||||
        }).always(function () {
 | 
			
		||||
            $('.notifications-wrapper .spinner').hide();
 | 
			
		||||
        })
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,48 +1,66 @@
 | 
			
		||||
// Rewrite this is a plugin.. is all this JS really 'worth it?'
 | 
			
		||||
(function ($) {
 | 
			
		||||
    $.fn.hashTabs = function (options) {
 | 
			
		||||
        var settings = $.extend({
 | 
			
		||||
            tabContainer: ".tabs ul",
 | 
			
		||||
            tabSelector: "li a",
 | 
			
		||||
            tabContent: ".tab-pane-inner",
 | 
			
		||||
            activeClass: "active",
 | 
			
		||||
            errorClass: ".messages .error",
 | 
			
		||||
            bodyClassToggle: "full-width"
 | 
			
		||||
        }, options);
 | 
			
		||||
 | 
			
		||||
window.addEventListener('hashchange', function () {
 | 
			
		||||
    var tabs = document.getElementsByClassName('active');
 | 
			
		||||
    while (tabs[0]) {
 | 
			
		||||
        tabs[0].classList.remove('active');
 | 
			
		||||
        document.body.classList.remove('full-width');
 | 
			
		||||
    }
 | 
			
		||||
    set_active_tab();
 | 
			
		||||
}, false);
 | 
			
		||||
        var $tabs = $(settings.tabContainer).find(settings.tabSelector);
 | 
			
		||||
 | 
			
		||||
var has_errors = document.querySelectorAll(".messages .error");
 | 
			
		||||
if (!has_errors.length) {
 | 
			
		||||
    if (document.location.hash == "") {
 | 
			
		||||
        location.replace(document.querySelector(".tabs ul li:first-child a").hash);
 | 
			
		||||
    } else {
 | 
			
		||||
        set_active_tab();
 | 
			
		||||
    }
 | 
			
		||||
} else {
 | 
			
		||||
    focus_error_tab();
 | 
			
		||||
}
 | 
			
		||||
        function setActiveTab() {
 | 
			
		||||
            var hash = window.location.hash;
 | 
			
		||||
            var $activeTab = $tabs.filter("[href='" + hash + "']");
 | 
			
		||||
 | 
			
		||||
function set_active_tab() {
 | 
			
		||||
    document.body.classList.remove('full-width');
 | 
			
		||||
    var tab = document.querySelectorAll("a[href='" + location.hash + "']");
 | 
			
		||||
    if (tab.length) {
 | 
			
		||||
        tab[0].parentElement.className = "active";
 | 
			
		||||
    }
 | 
			
		||||
            // Remove active class from all tabs
 | 
			
		||||
            $(settings.tabContainer).find("li").removeClass(settings.activeClass);
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
            // Add active class to selected tab
 | 
			
		||||
            if ($activeTab.length) {
 | 
			
		||||
                $activeTab.parent().addClass(settings.activeClass);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
function focus_error_tab() {
 | 
			
		||||
    // time to use jquery or vuejs really,
 | 
			
		||||
    // activate the tab with the error
 | 
			
		||||
    var tabs = document.querySelectorAll('.tabs li a'), i;
 | 
			
		||||
    for (i = 0; i < tabs.length; ++i) {
 | 
			
		||||
        var tab_name = tabs[i].hash.replace('#', '');
 | 
			
		||||
        var pane_errors = document.querySelectorAll('#' + tab_name + ' .error')
 | 
			
		||||
        if (pane_errors.length) {
 | 
			
		||||
            document.location.hash = '#' + tab_name;
 | 
			
		||||
            return true;
 | 
			
		||||
            // Show the correct content
 | 
			
		||||
            $(settings.tabContent).hide();
 | 
			
		||||
            if (hash) {
 | 
			
		||||
                $(hash).show();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        function focusErrorTab() {
 | 
			
		||||
            $tabs.each(function () {
 | 
			
		||||
                var tabName = this.hash.replace("#", "");
 | 
			
		||||
                if ($("#" + tabName).find(settings.errorClass).length) {
 | 
			
		||||
                    window.location.hash = "#" + tabName;
 | 
			
		||||
                    return false; // Stop loop on first error tab
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function initializeTabs() {
 | 
			
		||||
            if ($(settings.errorClass).length) {
 | 
			
		||||
                focusErrorTab();
 | 
			
		||||
            } else if (!window.location.hash) {
 | 
			
		||||
                window.location.replace($tabs.first().attr("href"));
 | 
			
		||||
            } else {
 | 
			
		||||
                setActiveTab();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Listen for hash changes
 | 
			
		||||
        $(window).on("hashchange", setActiveTab);
 | 
			
		||||
 | 
			
		||||
        // Initialize on page load
 | 
			
		||||
        initializeTabs();
 | 
			
		||||
 | 
			
		||||
        return this; // Enable jQuery chaining
 | 
			
		||||
    };
 | 
			
		||||
})(jQuery);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
$(document).ready(function () {
 | 
			
		||||
    $(".tabs").hashTabs();
 | 
			
		||||
});
 | 
			
		||||
@@ -380,7 +380,15 @@ a.pure-button-selected {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.notifications-wrapper {
 | 
			
		||||
  padding: 0.5rem 0 1rem 0;
 | 
			
		||||
  padding-top: 0.5rem;
 | 
			
		||||
  #notification-test-log {
 | 
			
		||||
    padding-top: 1rem;
 | 
			
		||||
    white-space: pre-wrap;
 | 
			
		||||
    word-break: break-word;
 | 
			
		||||
    overflow-wrap: break-word;
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
label {
 | 
			
		||||
@@ -937,15 +945,7 @@ $form-edge-padding: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tab-pane-inner {
 | 
			
		||||
 | 
			
		||||
  &:not(:target) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &:target {
 | 
			
		||||
    display: block;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  display: none;
 | 
			
		||||
  // doesnt need padding because theres another row of buttons/activity
 | 
			
		||||
  padding: 0px;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -780,7 +780,14 @@ a.pure-button-selected {
 | 
			
		||||
  cursor: pointer; }
 | 
			
		||||
 | 
			
		||||
.notifications-wrapper {
 | 
			
		||||
  padding: 0.5rem 0 1rem 0; }
 | 
			
		||||
  padding-top: 0.5rem; }
 | 
			
		||||
  .notifications-wrapper #notification-test-log {
 | 
			
		||||
    padding-top: 1rem;
 | 
			
		||||
    white-space: pre-wrap;
 | 
			
		||||
    word-break: break-word;
 | 
			
		||||
    overflow-wrap: break-word;
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
    box-sizing: border-box; }
 | 
			
		||||
 | 
			
		||||
label:hover {
 | 
			
		||||
  cursor: pointer; }
 | 
			
		||||
@@ -1152,11 +1159,8 @@ textarea::placeholder {
 | 
			
		||||
  border-radius: 5px; }
 | 
			
		||||
 | 
			
		||||
.tab-pane-inner {
 | 
			
		||||
  display: none;
 | 
			
		||||
  padding: 0px; }
 | 
			
		||||
  .tab-pane-inner:not(:target) {
 | 
			
		||||
    display: none; }
 | 
			
		||||
  .tab-pane-inner:target {
 | 
			
		||||
    display: block; }
 | 
			
		||||
 | 
			
		||||
.beta-logo {
 | 
			
		||||
  height: 50px;
 | 
			
		||||
 
 | 
			
		||||
@@ -24,11 +24,13 @@
 | 
			
		||||
                              </ul>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="notifications-wrapper">
 | 
			
		||||
                              <a id="send-test-notification" class="pure-button button-secondary button-xsmall" >Send test notification</a>
 | 
			
		||||
                              <a id="send-test-notification" class="pure-button button-secondary button-xsmall" >Send test notification</a> <div class="spinner"  style="display: none;"></div>
 | 
			
		||||
                            {% if emailprefix %}
 | 
			
		||||
                              <a id="add-email-helper" class="pure-button button-secondary button-xsmall" >Add email <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="Add an email address"> </a>
 | 
			
		||||
                            {% endif %}
 | 
			
		||||
                              <a href="{{url_for('notification_logs')}}" class="pure-button button-secondary button-xsmall" >Notification debug logs</a>
 | 
			
		||||
                              <br>
 | 
			
		||||
                                <div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">Processing..</span></div>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div id="notification-customisation" class="pure-control-group">
 | 
			
		||||
 
 | 
			
		||||
@@ -45,9 +45,8 @@
 | 
			
		||||
            {% if extra_tab_content %}
 | 
			
		||||
            <li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if playwright_enabled %}
 | 
			
		||||
            <li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        <!-- should goto extra forms? -->
 | 
			
		||||
            {% if watch['processor'] == 'text_json_diff' %}
 | 
			
		||||
            <li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
 | 
			
		||||
            <li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters & Triggers</a></li>
 | 
			
		||||
@@ -199,8 +198,9 @@ Math: {{ 1 + 1 }}") }}
 | 
			
		||||
                    </div>
 | 
			
		||||
            </fieldset>
 | 
			
		||||
            </div>
 | 
			
		||||
            {% if playwright_enabled %}
 | 
			
		||||
 | 
			
		||||
            <div class="tab-pane-inner" id="browser-steps">
 | 
			
		||||
            {% if playwright_enabled %}
 | 
			
		||||
                <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">
 | 
			
		||||
                <fieldset>
 | 
			
		||||
                    <div class="pure-control-group">
 | 
			
		||||
@@ -240,8 +240,16 @@ Math: {{ 1 + 1 }}") }}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
                {% else %}
 | 
			
		||||
                    <span class="pure-form-message-inline">
 | 
			
		||||
                        <p>Sorry, this functionality only works with Playwright/Chrome enabled watches.</p>
 | 
			
		||||
                        <p>Enable the Playwright Chrome fetcher, or alternatively try our <a href="https://lemonade.changedetection.io/start">very affordable subscription based service</a>.</p>
 | 
			
		||||
                        <p>This is because Selenium/WebDriver can not extract full page screenshots reliably.</p>
 | 
			
		||||
                        <p>You may need to <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">Enable playwright environment variable</a> and uncomment the <strong>sockpuppetbrowser</strong> from the docker-compose.yml file.</p>
 | 
			
		||||
                    </span>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
            </div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            <div class="tab-pane-inner" id="notifications">
 | 
			
		||||
                <fieldset>
 | 
			
		||||
@@ -493,6 +501,7 @@ keyword") }}
 | 
			
		||||
                                <p>Sorry, this functionality only works with Playwright/Chrome enabled watches.</p>
 | 
			
		||||
                                <p>Enable the Playwright Chrome fetcher, or alternatively try our <a href="https://lemonade.changedetection.io/start">very affordable subscription based service</a>.</p>
 | 
			
		||||
                                <p>This is because Selenium/WebDriver can not extract full page screenshots reliably.</p>
 | 
			
		||||
                                <p>You may need to <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">Enable playwright environment variable</a> and uncomment the <strong>sockpuppetbrowser</strong> from the docker-compose.yml file.</p>
 | 
			
		||||
                            </span>
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
                    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -280,9 +280,7 @@ nav
 | 
			
		||||
                        
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <p>
 | 
			
		||||
                    Your proxy provider may need to whitelist our IP of <code>204.15.192.195</code>
 | 
			
		||||
                </p>
 | 
			
		||||
 | 
			
		||||
               <p><strong>Tip</strong>: "Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites.
 | 
			
		||||
 | 
			
		||||
                <div class="pure-control-group" id="extra-proxies-setting">
 | 
			
		||||
 
 | 
			
		||||
@@ -108,7 +108,8 @@
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                    <a class="state-on" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                    <a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a>
 | 
			
		||||
                    {% set mute_label = 'UnMute notification' if watch.notification_muted else 'Mute notification' %}
 | 
			
		||||
                    <a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{{ mute_label }}" title="{{ mute_label }}" class="icon icon-mute" ></a>
 | 
			
		||||
                </td>
 | 
			
		||||
                <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}
 | 
			
		||||
                    <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a>
 | 
			
		||||
@@ -191,7 +192,7 @@
 | 
			
		||||
                    {% if watch.history_n >= 2 %}
 | 
			
		||||
 | 
			
		||||
                        {%  if is_unviewed %}
 | 
			
		||||
                           <a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_next_snapshot_key_to_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a>
 | 
			
		||||
                           <a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_from_version_based_on_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a>
 | 
			
		||||
                        {% else %}
 | 
			
		||||
                           <a href="{{ url_for('diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a>
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@ def test_execute_custom_js(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b"unpaused" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, "Watch history had atleast 1 (everything fetched OK)"
 | 
			
		||||
 | 
			
		||||
    assert b"This text should be removed" not in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -48,7 +48,7 @@ def test_noproxy_option(client, live_server, measure_memory_usage):
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Watch added in Paused state, saving will unpause" in res.data
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("edit_page", uuid=uuid, unpause_on_save=1))
 | 
			
		||||
    assert b'No proxy' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -81,7 +81,7 @@ def test_socks5(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert "Awesome, you made it".encode('utf-8') in res.data
 | 
			
		||||
 | 
			
		||||
    # PROXY CHECKER WIDGET CHECK - this needs more checking
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("check_proxies.start_check", uuid=uuid),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import os.path
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output
 | 
			
		||||
from changedetectionio import html_tools
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_original(excluding=None, add_line=None):
 | 
			
		||||
 
 | 
			
		||||
@@ -44,7 +44,6 @@ def set_modified_response():
 | 
			
		||||
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_valid_uuid(val):
 | 
			
		||||
    try:
 | 
			
		||||
        uuid.UUID(str(val))
 | 
			
		||||
@@ -56,8 +55,9 @@ def is_valid_uuid(val):
 | 
			
		||||
def test_setup(client, live_server, measure_memory_usage):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_api_simple(client, live_server, measure_memory_usage):
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
#    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    api_key = extract_api_key_from_UI(client)
 | 
			
		||||
 | 
			
		||||
@@ -129,6 +129,9 @@ def test_api_simple(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert after_recheck_info['last_checked'] != before_recheck_info['last_checked']
 | 
			
		||||
    assert after_recheck_info['last_changed'] != 0
 | 
			
		||||
 | 
			
		||||
    # #2877 When run in a slow fetcher like playwright etc
 | 
			
		||||
    assert after_recheck_info['last_changed'] ==  after_recheck_info['last_checked']
 | 
			
		||||
 | 
			
		||||
    # Check history index list
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("watchhistory", uuid=watch_uuid),
 | 
			
		||||
 
 | 
			
		||||
@@ -99,7 +99,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage
 | 
			
		||||
    assert b'ldjson-price-track-offer' in res.data
 | 
			
		||||
 | 
			
		||||
    # Accept it
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    #time.sleep(1)
 | 
			
		||||
    client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True))
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@
 | 
			
		||||
 | 
			
		||||
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, extract_rss_token_from_UI, \
 | 
			
		||||
    extract_UUID_from_client
 | 
			
		||||
 | 
			
		||||
@@ -69,7 +68,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
 | 
			
		||||
    # Check the 'get latest snapshot works'
 | 
			
		||||
    res = client.get(url_for("watch_get_latest_html", uuid=uuid))
 | 
			
		||||
 
 | 
			
		||||
@@ -40,7 +40,7 @@ def test_check_encoding_detection(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Content type recording worked
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['content-type'] == "text/html"
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
 
 | 
			
		||||
@@ -51,7 +51,7 @@ def run_filter_test(client, live_server, content_filter):
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
 | 
			
		||||
    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -288,7 +288,7 @@ def test_clone_tag_on_import(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b'test-tag' in res.data
 | 
			
		||||
    assert b'another-tag' in res.data
 | 
			
		||||
 | 
			
		||||
    watch_uuid = extract_UUID_from_client(client)
 | 
			
		||||
    watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    res = client.get(url_for("form_clone", uuid=watch_uuid), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
    assert b'Cloned' in res.data
 | 
			
		||||
@@ -315,7 +315,7 @@ def test_clone_tag_on_quickwatchform_add(client, live_server, measure_memory_usa
 | 
			
		||||
    assert b'test-tag' in res.data
 | 
			
		||||
    assert b'another-tag' in res.data
 | 
			
		||||
 | 
			
		||||
    watch_uuid = extract_UUID_from_client(client)
 | 
			
		||||
    watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    res = client.get(url_for("form_clone", uuid=watch_uuid), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
    assert b'Cloned' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ def test_ignore(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    # use the highlighter endpoint
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("highlight_submit_ignore_url", uuid=uuid),
 | 
			
		||||
 
 | 
			
		||||
@@ -514,3 +514,15 @@ def test_check_jq_ext_filter(client, live_server, measure_memory_usage):
 | 
			
		||||
def test_check_jqraw_ext_filter(client, live_server, measure_memory_usage):
 | 
			
		||||
    if jq_support:
 | 
			
		||||
        check_json_ext_filter('jq:.[] | select(.status | contains("Sold"))', client, live_server)
 | 
			
		||||
 | 
			
		||||
def test_jsonpath_BOM_utf8(client, live_server, measure_memory_usage):
 | 
			
		||||
    from .. import html_tools
 | 
			
		||||
 | 
			
		||||
    # JSON string with BOM and correct double-quoted keys
 | 
			
		||||
    json_str = '\ufeff{"name": "José", "emoji": "😊", "language": "中文", "greeting": "Привет"}'
 | 
			
		||||
 | 
			
		||||
    # See that we can find the second <script> one, which is not broken, and matches our filter
 | 
			
		||||
    text = html_tools.extract_json_as_string(json_str, "json:$.name")
 | 
			
		||||
    assert text == '"José"'
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
 
 | 
			
		||||
@@ -29,7 +29,7 @@ def test_content_filter_live_preview(client, live_server, measure_memory_usage):
 | 
			
		||||
        data={"url": test_url, "tags": ''},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid=uuid),
 | 
			
		||||
        data={
 | 
			
		||||
 
 | 
			
		||||
@@ -48,7 +48,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    #####################
 | 
			
		||||
    client.post(
 | 
			
		||||
        url_for("settings_page"),
 | 
			
		||||
        data={"application-empty_pages_are_a_change": "",
 | 
			
		||||
        data={"application-empty_pages_are_a_change": "", # default, OFF, they are NOT a change
 | 
			
		||||
              "requests-time_between_check-minutes": 180,
 | 
			
		||||
              'application-fetch_backend': "html_requests"},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
@@ -66,6 +66,14 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
 | 
			
		||||
 | 
			
		||||
    assert watch.last_changed == 0
 | 
			
		||||
    assert watch['last_checked'] != 0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # ok now do the opposite
 | 
			
		||||
 | 
			
		||||
@@ -92,6 +100,10 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    # A totally zero byte (#2528) response should also not trigger an error
 | 
			
		||||
    set_zero_byte_response()
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
    # 2877
 | 
			
		||||
    assert watch.last_changed == watch['last_checked']
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'unviewed' in res.data # A change should have registered because empty_pages_are_a_change is ON
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ from flask import url_for
 | 
			
		||||
from loguru import logger
 | 
			
		||||
 | 
			
		||||
from .util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, wait_for_all_checks, \
 | 
			
		||||
    set_longer_modified_response
 | 
			
		||||
    set_longer_modified_response, get_index
 | 
			
		||||
from . util import  extract_UUID_from_client
 | 
			
		||||
import logging
 | 
			
		||||
import base64
 | 
			
		||||
@@ -29,7 +29,7 @@ def test_check_notification(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    # 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')
 | 
			
		||||
    notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')+"?status_code=204"
 | 
			
		||||
 | 
			
		||||
    assert default_notification_body.encode() in res.data
 | 
			
		||||
    assert default_notification_title.encode() in res.data
 | 
			
		||||
@@ -76,7 +76,7 @@ def test_check_notification(client, live_server, measure_memory_usage):
 | 
			
		||||
    testimage_png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    datastore = 'test-datastore'
 | 
			
		||||
    with open(os.path.join(datastore, str(uuid), 'last-screenshot.png'), 'wb') as f:
 | 
			
		||||
        f.write(base64.b64decode(testimage_png))
 | 
			
		||||
@@ -135,7 +135,14 @@ def test_check_notification(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    # Trigger a check
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
 | 
			
		||||
    # Check no errors were recorded
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'notification-error' not in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Verify what was sent as a notification, this file should exist
 | 
			
		||||
    with open("test-datastore/notification.txt", "r") as f:
 | 
			
		||||
        notification_submission = f.read()
 | 
			
		||||
@@ -284,7 +291,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
 | 
			
		||||
    # CUSTOM JSON BODY CHECK for POST://
 | 
			
		||||
    set_original_response()
 | 
			
		||||
    # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation
 | 
			
		||||
    test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123&+second=hello+world%20%22space%22"
 | 
			
		||||
    test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?status_code=204&xxx={{ watch_url }}&+custom-header=123&+second=hello+world%20%22space%22"
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("settings_page"),
 | 
			
		||||
@@ -319,6 +326,11 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
 | 
			
		||||
 | 
			
		||||
    time.sleep(2) # plus extra delay for notifications to fire
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Check no errors were recorded, because we asked for 204 which is slightly uncommon but is still OK
 | 
			
		||||
    res = get_index(client)
 | 
			
		||||
    assert b'notification-error' not in res.data
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/notification.txt", 'r') as f:
 | 
			
		||||
        x = f.read()
 | 
			
		||||
        j = json.loads(x)
 | 
			
		||||
@@ -360,7 +372,10 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    set_original_response()
 | 
			
		||||
    if os.path.isfile("test-datastore/notification.txt"):
 | 
			
		||||
        os.unlink("test-datastore/notification.txt")
 | 
			
		||||
        os.unlink("test-datastore/notification.txt") \
 | 
			
		||||
 | 
			
		||||
    # 1995 UTF-8 content should be encoded
 | 
			
		||||
    test_body = 'change detection is cool 网站监测 内容更新了'
 | 
			
		||||
 | 
			
		||||
    # otherwise other settings would have already existed from previous tests in this file
 | 
			
		||||
    res = client.post(
 | 
			
		||||
@@ -368,8 +383,7 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
 | 
			
		||||
        data={
 | 
			
		||||
            "application-fetch_backend": "html_requests",
 | 
			
		||||
            "application-minutes_between_check": 180,
 | 
			
		||||
            #1995 UTF-8 content should be encoded
 | 
			
		||||
            "application-notification_body": 'change detection is cool 网站监测 内容更新了',
 | 
			
		||||
            "application-notification_body": test_body,
 | 
			
		||||
            "application-notification_format": default_notification_format,
 | 
			
		||||
            "application-notification_urls": "",
 | 
			
		||||
            "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
 | 
			
		||||
@@ -399,12 +413,10 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
 | 
			
		||||
    assert res.status_code != 400
 | 
			
		||||
    assert res.status_code != 500
 | 
			
		||||
 | 
			
		||||
    # Give apprise time to fire
 | 
			
		||||
    time.sleep(4)
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/notification.txt", 'r') as f:
 | 
			
		||||
        x = f.read()
 | 
			
		||||
        assert 'change detection is cool 网站监测 内容更新了' in x
 | 
			
		||||
        assert test_body in x
 | 
			
		||||
 | 
			
		||||
    os.unlink("test-datastore/notification.txt")
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -373,13 +373,14 @@ def test_headers_textfile_in_request(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    with open('test-datastore/headers-testtag.txt', 'w') as f:
 | 
			
		||||
        f.write("tag-header: test")
 | 
			
		||||
        f.write("tag-header: test\r\nurl-header: http://example.com")
 | 
			
		||||
 | 
			
		||||
    with open('test-datastore/headers.txt', 'w') as f:
 | 
			
		||||
        f.write("global-header: nice\r\nnext-global-header: nice")
 | 
			
		||||
        f.write("global-header: nice\r\nnext-global-header: nice\r\nurl-header-global: http://example.com/global")
 | 
			
		||||
 | 
			
		||||
    with open('test-datastore/' + extract_UUID_from_client(client) + '/headers.txt', 'w') as f:
 | 
			
		||||
        f.write("watch-header: nice")
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    with open(f'test-datastore/{uuid}/headers.txt', 'w') as f:
 | 
			
		||||
        f.write("watch-header: nice\r\nurl-header-watch: http://example.com/watch")
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
@@ -410,6 +411,9 @@ def test_headers_textfile_in_request(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b"Xxx:ooo" in res.data
 | 
			
		||||
    assert b"Watch-Header:nice" in res.data
 | 
			
		||||
    assert b"Tag-Header:test" in res.data
 | 
			
		||||
    assert b"Url-Header:http://example.com" in res.data
 | 
			
		||||
    assert b"Url-Header-Global:http://example.com/global" in res.data
 | 
			
		||||
    assert b"Url-Header-Watch:http://example.com/watch" in res.data
 | 
			
		||||
 | 
			
		||||
    # Check the custom UA from system settings page made it through
 | 
			
		||||
    if os.getenv('PLAYWRIGHT_DRIVER_URL'):
 | 
			
		||||
 
 | 
			
		||||
@@ -189,6 +189,17 @@ def _run_test_minmax_limit(client, extra_watch_edit_form):
 | 
			
		||||
 | 
			
		||||
    client.get(url_for("mark_all_viewed"))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # 2715 - Price detection (once it crosses the "lower" threshold) again with a lower price - should trigger again!
 | 
			
		||||
    set_original_response(props_markup=instock_props[0], price='820.45')
 | 
			
		||||
    res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    assert b'1 watches queued for rechecking.' in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'820.45' in res.data
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
    client.get(url_for("mark_all_viewed"))
 | 
			
		||||
 | 
			
		||||
    # price changed to something MORE than max (1100.10), SHOULD be a change
 | 
			
		||||
    set_original_response(props_markup=instock_props[0], price='1890.45')
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
@@ -203,7 +214,7 @@ def _run_test_minmax_limit(client, extra_watch_edit_form):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_restock_itemprop_minmax(client, live_server):
 | 
			
		||||
#    live_server_setup(live_server)
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    extras = {
 | 
			
		||||
        "restock_settings-follow_price_changes": "y",
 | 
			
		||||
        "restock_settings-price_change_min": 900.0,
 | 
			
		||||
@@ -369,7 +380,7 @@ def test_change_with_notification_values(client, live_server):
 | 
			
		||||
 | 
			
		||||
    ## Now test the "SEND TEST NOTIFICATION" is working
 | 
			
		||||
    os.unlink("test-datastore/notification.txt")
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    res = client.post(url_for("ajax_callback_send_notification_test", watch_uuid=uuid), data={}, follow_redirects=True)
 | 
			
		||||
    time.sleep(5)
 | 
			
		||||
    assert os.path.isfile("test-datastore/notification.txt"), "Notification received"
 | 
			
		||||
 
 | 
			
		||||
@@ -132,7 +132,7 @@ def test_rss_xpath_filtering(client, live_server, measure_memory_usage):
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Watch added in Paused state, saving will unpause" in res.data
 | 
			
		||||
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid=uuid, unpause_on_save=1),
 | 
			
		||||
        data={
 | 
			
		||||
 
 | 
			
		||||
@@ -39,7 +39,7 @@ def test_check_basic_scheduler_functionality(client, live_server, measure_memory
 | 
			
		||||
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
 | 
			
		||||
    # Setup all the days of the weeks using XXX as the placeholder for monday/tuesday/etc
 | 
			
		||||
 | 
			
		||||
@@ -104,7 +104,7 @@ def test_check_basic_global_scheduler_functionality(client, live_server, measure
 | 
			
		||||
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
 | 
			
		||||
    # Setup all the days of the weeks using XXX as the placeholder for monday/tuesday/etc
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,7 @@
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
from .. import strtobool
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -61,54 +59,44 @@ def test_bad_access(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b'Watch protocol is not permitted by SAFE_PROTOCOL_REGEX' in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_file_slashslash_access(client, live_server, measure_memory_usage):
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
def _runner_test_various_file_slash(client, file_uri):
 | 
			
		||||
 | 
			
		||||
    test_file_path = os.path.abspath(__file__)
 | 
			
		||||
 | 
			
		||||
    # file:// is permitted by default, but it will be caught by ALLOW_FILE_URI
 | 
			
		||||
    client.post(
 | 
			
		||||
        url_for("form_quick_watch_add"),
 | 
			
		||||
        data={"url": f"file://{test_file_path}", "tags": ''},
 | 
			
		||||
        data={"url": file_uri, "tags": ''},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
 | 
			
		||||
    substrings = [b"URLs with hostname components are not permitted", b"No connection adapters were found for"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # If it is enabled at test time
 | 
			
		||||
    if strtobool(os.getenv('ALLOW_FILE_URI', 'false')):
 | 
			
		||||
        res = client.get(
 | 
			
		||||
            url_for("preview_page", uuid="first"),
 | 
			
		||||
            follow_redirects=True
 | 
			
		||||
        )
 | 
			
		||||
        if file_uri.startswith('file:///'):
 | 
			
		||||
            # This one should be the full qualified path to the file and should get the contents of this file
 | 
			
		||||
            res = client.get(
 | 
			
		||||
                url_for("preview_page", uuid="first"),
 | 
			
		||||
                follow_redirects=True
 | 
			
		||||
            )
 | 
			
		||||
            assert b'_runner_test_various_file_slash' in res.data
 | 
			
		||||
        else:
 | 
			
		||||
            # This will give some error from requests or if it went to chrome, will give some other error :-)
 | 
			
		||||
            assert any(s in res.data for s in substrings)
 | 
			
		||||
 | 
			
		||||
        assert b"test_file_slashslash_access" in res.data
 | 
			
		||||
    else:
 | 
			
		||||
        # Default should be here
 | 
			
		||||
        assert b'file:// type access is denied for security reasons.' in res.data
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 | 
			
		||||
def test_file_slash_access(client, live_server, measure_memory_usage):
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    # file: is NOT permitted by default, so it will be caught by ALLOW_FILE_URI check
 | 
			
		||||
 | 
			
		||||
    test_file_path = os.path.abspath(__file__)
 | 
			
		||||
 | 
			
		||||
    # file:// is permitted by default, but it will be caught by ALLOW_FILE_URI
 | 
			
		||||
    client.post(
 | 
			
		||||
        url_for("form_quick_watch_add"),
 | 
			
		||||
        data={"url": f"file:/{test_file_path}", "tags": ''},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
 | 
			
		||||
    # If it is enabled at test time
 | 
			
		||||
    if strtobool(os.getenv('ALLOW_FILE_URI', 'false')):
 | 
			
		||||
        # So it should permit it, but it should fall back to the 'requests' library giving an error
 | 
			
		||||
        # (but means it gets passed to playwright etc)
 | 
			
		||||
        assert b"URLs with hostname components are not permitted" in res.data
 | 
			
		||||
    else:
 | 
			
		||||
        # Default should be here
 | 
			
		||||
        assert b'file:// type access is denied for security reasons.' in res.data
 | 
			
		||||
    _runner_test_various_file_slash(client, file_uri=f"file://{test_file_path}")
 | 
			
		||||
    _runner_test_various_file_slash(client, file_uri=f"file:/{test_file_path}")
 | 
			
		||||
    _runner_test_various_file_slash(client, file_uri=f"file:{test_file_path}") # CVE-2024-56509
 | 
			
		||||
 | 
			
		||||
def test_xss(client, live_server, measure_memory_usage):
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										64
									
								
								changedetectionio/tests/unit/test_semver.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								changedetectionio/tests/unit/test_semver.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,64 @@
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
# run from dir above changedetectionio/ dir
 | 
			
		||||
# python3 -m unittest changedetectionio.tests.unit.test_semver
 | 
			
		||||
 | 
			
		||||
import re
 | 
			
		||||
import unittest
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# The SEMVER regex
 | 
			
		||||
SEMVER_REGEX = r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
 | 
			
		||||
 | 
			
		||||
# Compile the regex
 | 
			
		||||
semver_pattern = re.compile(SEMVER_REGEX)
 | 
			
		||||
 | 
			
		||||
class TestSemver(unittest.TestCase):
 | 
			
		||||
    def test_valid_versions(self):
 | 
			
		||||
        """Test valid semantic version strings"""
 | 
			
		||||
        valid_versions = [
 | 
			
		||||
            "1.0.0",
 | 
			
		||||
            "0.1.0",
 | 
			
		||||
            "0.0.1",
 | 
			
		||||
            "1.0.0-alpha",
 | 
			
		||||
            "1.0.0-alpha.1",
 | 
			
		||||
            "1.0.0-0.3.7",
 | 
			
		||||
            "1.0.0-x.7.z.92",
 | 
			
		||||
            "1.0.0-alpha+001",
 | 
			
		||||
            "1.0.0+20130313144700",
 | 
			
		||||
            "1.0.0-beta+exp.sha.5114f85"
 | 
			
		||||
        ]
 | 
			
		||||
        for version in valid_versions:
 | 
			
		||||
            with self.subTest(version=version):
 | 
			
		||||
                self.assertIsNotNone(semver_pattern.match(version), f"Version {version} should be valid")
 | 
			
		||||
 | 
			
		||||
    def test_invalid_versions(self):
 | 
			
		||||
        """Test invalid semantic version strings"""
 | 
			
		||||
        invalid_versions = [
 | 
			
		||||
            "0.48.06",
 | 
			
		||||
            "1.0",
 | 
			
		||||
            "1.0.0-",
 | 
			
		||||
# Seems to pass the semver.org regex?
 | 
			
		||||
#            "1.0.0-alpha-",
 | 
			
		||||
            "1.0.0+",
 | 
			
		||||
            "1.0.0-alpha+",
 | 
			
		||||
            "1.0.0-",
 | 
			
		||||
            "01.0.0",
 | 
			
		||||
            "1.01.0",
 | 
			
		||||
            "1.0.01",
 | 
			
		||||
            ".1.0.0",
 | 
			
		||||
            "1..0.0"
 | 
			
		||||
        ]
 | 
			
		||||
        for version in invalid_versions:
 | 
			
		||||
            with self.subTest(version=version):
 | 
			
		||||
                res = semver_pattern.match(version)
 | 
			
		||||
                self.assertIsNone(res, f"Version '{version}' should be invalid")
 | 
			
		||||
 | 
			
		||||
    def test_our_version(self):
 | 
			
		||||
        from changedetectionio import get_version
 | 
			
		||||
        our_version = get_version()
 | 
			
		||||
        self.assertIsNotNone(semver_pattern.match(our_version), f"Our version '{our_version}' should be a valid SEMVER string")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    unittest.main()
 | 
			
		||||
@@ -16,7 +16,6 @@ class TestDiffBuilder(unittest.TestCase):
 | 
			
		||||
        watch = Watch.model(datastore_path='/tmp', default={})
 | 
			
		||||
        watch.ensure_data_dir_exists()
 | 
			
		||||
 | 
			
		||||
        watch['last_viewed'] = 110
 | 
			
		||||
 | 
			
		||||
        # Contents from the browser are always returned from the browser/requests/etc as str, str is basically UTF-16 in python
 | 
			
		||||
        watch.save_history_text(contents="hello world", timestamp=100, snapshot_id=str(uuid_builder.uuid4()))
 | 
			
		||||
@@ -25,31 +24,42 @@ class TestDiffBuilder(unittest.TestCase):
 | 
			
		||||
        watch.save_history_text(contents="hello world", timestamp=112, snapshot_id=str(uuid_builder.uuid4()))
 | 
			
		||||
        watch.save_history_text(contents="hello world", timestamp=115, snapshot_id=str(uuid_builder.uuid4()))
 | 
			
		||||
        watch.save_history_text(contents="hello world", timestamp=117, snapshot_id=str(uuid_builder.uuid4()))
 | 
			
		||||
    
 | 
			
		||||
        p = watch.get_from_version_based_on_last_viewed
 | 
			
		||||
        assert p == "100", "Correct 'last viewed' timestamp was detected"
 | 
			
		||||
 | 
			
		||||
        p = watch.get_next_snapshot_key_to_last_viewed
 | 
			
		||||
        assert p == "112", "Correct last-viewed timestamp was detected"
 | 
			
		||||
        watch['last_viewed'] = 110
 | 
			
		||||
        p = watch.get_from_version_based_on_last_viewed
 | 
			
		||||
        assert p == "109", "Correct 'last viewed' timestamp was detected"
 | 
			
		||||
 | 
			
		||||
        # When there is only one step of difference from the end of the list, it should return second-last change
 | 
			
		||||
        watch['last_viewed'] = 116
 | 
			
		||||
        p = watch.get_next_snapshot_key_to_last_viewed
 | 
			
		||||
        assert p == "115", "Correct 'second last' last-viewed timestamp was detected when using the last timestamp"
 | 
			
		||||
        p = watch.get_from_version_based_on_last_viewed
 | 
			
		||||
        assert p == "115", "Correct 'last viewed' timestamp was detected"
 | 
			
		||||
 | 
			
		||||
        watch['last_viewed'] = 99
 | 
			
		||||
        p = watch.get_next_snapshot_key_to_last_viewed
 | 
			
		||||
        assert p == "100"
 | 
			
		||||
        p = watch.get_from_version_based_on_last_viewed
 | 
			
		||||
        assert p == "100", "When the 'last viewed' timestamp is less than the oldest snapshot, return oldest"
 | 
			
		||||
 | 
			
		||||
        watch['last_viewed'] = 200
 | 
			
		||||
        p = watch.get_next_snapshot_key_to_last_viewed
 | 
			
		||||
        assert p == "115", "When the 'last viewed' timestamp is greater than the newest snapshot, return second last "
 | 
			
		||||
        p = watch.get_from_version_based_on_last_viewed
 | 
			
		||||
        assert p == "115", "When the 'last viewed' timestamp is greater than the newest snapshot, return second newest"
 | 
			
		||||
 | 
			
		||||
        watch['last_viewed'] = 109
 | 
			
		||||
        p = watch.get_next_snapshot_key_to_last_viewed
 | 
			
		||||
        p = watch.get_from_version_based_on_last_viewed
 | 
			
		||||
        assert p == "109", "Correct when its the same time"
 | 
			
		||||
 | 
			
		||||
        # new empty one
 | 
			
		||||
        watch = Watch.model(datastore_path='/tmp', default={})
 | 
			
		||||
        p = watch.get_next_snapshot_key_to_last_viewed
 | 
			
		||||
        p = watch.get_from_version_based_on_last_viewed
 | 
			
		||||
        assert p == None, "None when no history available"
 | 
			
		||||
 | 
			
		||||
        watch.save_history_text(contents="hello world", timestamp=100, snapshot_id=str(uuid_builder.uuid4()))
 | 
			
		||||
        p = watch.get_from_version_based_on_last_viewed
 | 
			
		||||
        assert p == "100", "Correct with only one history snapshot"
 | 
			
		||||
 | 
			
		||||
        watch['last_viewed'] = 200
 | 
			
		||||
        p = watch.get_from_version_based_on_last_viewed
 | 
			
		||||
        assert p == "100", "Correct with only one history snapshot"
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    unittest.main()
 | 
			
		||||
 
 | 
			
		||||
@@ -76,6 +76,14 @@ def set_more_modified_response():
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_empty_text_response():
 | 
			
		||||
    test_return_data = """<html><body></body></html>"""
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "w") as f:
 | 
			
		||||
        f.write(test_return_data)
 | 
			
		||||
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def wait_for_notification_endpoint_output():
 | 
			
		||||
    '''Apprise can take a few seconds to fire'''
 | 
			
		||||
    #@todo - could check the apprise object directly instead of looking for this file
 | 
			
		||||
@@ -215,9 +223,10 @@ def live_server_setup(live_server):
 | 
			
		||||
    def test_method():
 | 
			
		||||
        return request.method
 | 
			
		||||
 | 
			
		||||
    # Where we POST to as a notification
 | 
			
		||||
    @live_server.app.route('/test_notification_endpoint', methods=['POST', 'GET'])
 | 
			
		||||
    # Where we POST to as a notification, also use a space here to test URL escaping is OK across all tests that use this. ( #2868 )
 | 
			
		||||
    @live_server.app.route('/test_notification endpoint', methods=['POST', 'GET'])
 | 
			
		||||
    def test_notification_endpoint():
 | 
			
		||||
 | 
			
		||||
        with open("test-datastore/notification.txt", "wb") as f:
 | 
			
		||||
            # Debug method, dump all POST to file also, used to prove #65
 | 
			
		||||
            data = request.stream.read()
 | 
			
		||||
@@ -235,8 +244,11 @@ def live_server_setup(live_server):
 | 
			
		||||
                f.write(request.content_type)
 | 
			
		||||
 | 
			
		||||
        print("\n>> Test notification endpoint was hit.\n", data)
 | 
			
		||||
        return "Text was set"
 | 
			
		||||
 | 
			
		||||
        content = "Text was set"
 | 
			
		||||
        status_code = request.args.get('status_code',200)
 | 
			
		||||
        resp = make_response(content, status_code)
 | 
			
		||||
        return resp
 | 
			
		||||
 | 
			
		||||
    # Just return the verb in the request
 | 
			
		||||
    @live_server.app.route('/test-basicauth', methods=['GET'])
 | 
			
		||||
@@ -273,15 +285,43 @@ def live_server_setup(live_server):
 | 
			
		||||
            <p id="remove">This text should be removed</p>
 | 
			
		||||
              <form onsubmit="event.preventDefault();">
 | 
			
		||||
            <!-- obfuscated text so that we dont accidentally get a false positive due to conversion of the source :) --->
 | 
			
		||||
                <button name="test-button" onclick="getElementById('remove').remove();getElementById('some-content').innerHTML = atob('SSBzbWVsbCBKYXZhU2NyaXB0IGJlY2F1c2UgdGhlIGJ1dHRvbiB3YXMgcHJlc3NlZCE=')">Click here</button>
 | 
			
		||||
                <div id=some-content></div>
 | 
			
		||||
                <button name="test-button" onclick="
 | 
			
		||||
                getElementById('remove').remove();
 | 
			
		||||
                getElementById('some-content').innerHTML = atob('SSBzbWVsbCBKYXZhU2NyaXB0IGJlY2F1c2UgdGhlIGJ1dHRvbiB3YXMgcHJlc3NlZCE=');
 | 
			
		||||
                getElementById('reflect-text').innerHTML = getElementById('test-input-text').value;
 | 
			
		||||
                ">Click here</button>
 | 
			
		||||
                
 | 
			
		||||
                <div id="some-content"></div>
 | 
			
		||||
                
 | 
			
		||||
                <pre>
 | 
			
		||||
                {header_text.lower()}
 | 
			
		||||
                </pre>
 | 
			
		||||
              </body>
 | 
			
		||||
                
 | 
			
		||||
                <br>
 | 
			
		||||
                <!-- used for testing that the jinja2 compiled here --->
 | 
			
		||||
                <input type="text" value="" id="test-input-text" /><br>
 | 
			
		||||
                <div id="reflect-text">Waiting to reflect text from #test-input-text here</div>
 | 
			
		||||
              </form>
 | 
			
		||||
                
 | 
			
		||||
           </body>
 | 
			
		||||
         </html>""", 200)
 | 
			
		||||
        resp.headers['Content-Type'] = 'text/html'
 | 
			
		||||
        return resp
 | 
			
		||||
 | 
			
		||||
    live_server.start()
 | 
			
		||||
 | 
			
		||||
def get_index(client):
 | 
			
		||||
    import inspect
 | 
			
		||||
    # Get the caller's frame (parent function)
 | 
			
		||||
    frame = inspect.currentframe()
 | 
			
		||||
    caller_frame = frame.f_back  # Go back to the caller's frame
 | 
			
		||||
    caller_name = caller_frame.f_code.co_name
 | 
			
		||||
    caller_line = caller_frame.f_lineno
 | 
			
		||||
 | 
			
		||||
    print(f"Called by: {caller_name}, Line: {caller_line}")
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    with open(f"test-datastore/index-{caller_name}-{caller_line}.html", 'wb') as f:
 | 
			
		||||
        f.write(res.data)
 | 
			
		||||
 | 
			
		||||
    return res
 | 
			
		||||
 
 | 
			
		||||
@@ -2,14 +2,16 @@
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
 | 
			
		||||
from ..util import live_server_setup, wait_for_all_checks, get_index
 | 
			
		||||
 | 
			
		||||
def test_setup(client, live_server, measure_memory_usage):
 | 
			
		||||
def test_setup(client, live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# 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, measure_memory_usage):
 | 
			
		||||
    live_server.stop()
 | 
			
		||||
    live_server.start()
 | 
			
		||||
 | 
			
		||||
    import os
 | 
			
		||||
    import json
 | 
			
		||||
@@ -27,7 +29,7 @@ def test_visual_selector_content_ready(client, live_server, measure_memory_usage
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Watch added in Paused state, saving will unpause" in res.data
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid=uuid, unpause_on_save=1),
 | 
			
		||||
        data={
 | 
			
		||||
@@ -87,7 +89,9 @@ def test_visual_selector_content_ready(client, live_server, measure_memory_usage
 | 
			
		||||
 | 
			
		||||
def test_basic_browserstep(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    live_server.stop()
 | 
			
		||||
    live_server.start()
 | 
			
		||||
 | 
			
		||||
    assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test"
 | 
			
		||||
 | 
			
		||||
    test_url = url_for('test_interactive_html_endpoint', _external=True)
 | 
			
		||||
@@ -108,9 +112,13 @@ def test_basic_browserstep(client, live_server, measure_memory_usage):
 | 
			
		||||
            "url": test_url,
 | 
			
		||||
            "tags": "",
 | 
			
		||||
            'fetch_backend': "html_webdriver",
 | 
			
		||||
            'browser_steps-0-operation': 'Click element',
 | 
			
		||||
            'browser_steps-0-selector': 'button[name=test-button]',
 | 
			
		||||
            'browser_steps-0-optional_value': '',
 | 
			
		||||
            'browser_steps-0-operation': 'Enter text in field',
 | 
			
		||||
            'browser_steps-0-selector': '#test-input-text',
 | 
			
		||||
            # Should get set to the actual text (jinja2 rendered)
 | 
			
		||||
            'browser_steps-0-optional_value': "Hello-Jinja2-{% now  'Europe/Berlin', '%Y-%m-%d' %}",
 | 
			
		||||
            'browser_steps-1-operation': 'Click element',
 | 
			
		||||
            'browser_steps-1-selector': 'button[name=test-button]',
 | 
			
		||||
            'browser_steps-1-optional_value': '',
 | 
			
		||||
            # For now, cookies doesnt work in headers because it must be a full cookiejar object
 | 
			
		||||
            'headers': "testheader: yes\buser-agent: MyCustomAgent",
 | 
			
		||||
        },
 | 
			
		||||
@@ -119,7 +127,7 @@ def test_basic_browserstep(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b"unpaused" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, "Watch history had atleast 1 (everything fetched OK)"
 | 
			
		||||
 | 
			
		||||
    assert b"This text should be removed" not in res.data
 | 
			
		||||
@@ -132,13 +140,32 @@ def test_basic_browserstep(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b"This text should be removed" not in res.data
 | 
			
		||||
    assert b"I smell JavaScript because the button was pressed" in res.data
 | 
			
		||||
 | 
			
		||||
    assert b'Hello-Jinja2-20' in res.data
 | 
			
		||||
 | 
			
		||||
    assert b"testheader: yes" in res.data
 | 
			
		||||
    assert b"user-agent: mycustomagent" in res.data
 | 
			
		||||
    live_server.stop()
 | 
			
		||||
 | 
			
		||||
def test_non_200_errors_report_browsersteps(client, live_server):
 | 
			
		||||
 | 
			
		||||
    live_server.stop()
 | 
			
		||||
    live_server.start()
 | 
			
		||||
 | 
			
		||||
    four_o_four_url =  url_for('test_endpoint', status_code=404, _external=True)
 | 
			
		||||
    four_o_four_url = four_o_four_url.replace('localhost.localdomain', 'cdio')
 | 
			
		||||
    four_o_four_url = four_o_four_url.replace('localhost', 'cdio')
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("form_quick_watch_add"),
 | 
			
		||||
        data={"url": four_o_four_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"Watch added in Paused state, saving will unpause" in res.data
 | 
			
		||||
    assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test"
 | 
			
		||||
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
 | 
			
		||||
    # now test for 404 errors
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid=uuid, unpause_on_save=1),
 | 
			
		||||
@@ -153,12 +180,14 @@ def test_basic_browserstep(client, live_server, measure_memory_usage):
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"unpaused" in res.data
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = get_index(client)
 | 
			
		||||
 | 
			
		||||
    assert b'Error - 404' in res.data
 | 
			
		||||
 | 
			
		||||
    client.get(
 | 
			
		||||
        url_for("form_delete", uuid="all"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    )
 | 
			
		||||
 
 | 
			
		||||
@@ -243,7 +243,6 @@ class update_worker(threading.Thread):
 | 
			
		||||
                os.unlink(full_path)
 | 
			
		||||
 | 
			
		||||
    def run(self):
 | 
			
		||||
        now = time.time()
 | 
			
		||||
        
 | 
			
		||||
        while not self.app.config.exit.is_set():
 | 
			
		||||
            update_handler = None
 | 
			
		||||
@@ -254,6 +253,7 @@ class update_worker(threading.Thread):
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
            else:
 | 
			
		||||
                fetch_start_time = time.time()
 | 
			
		||||
                uuid = queued_item_data.item.get('uuid')
 | 
			
		||||
                self.current_uuid = uuid
 | 
			
		||||
                if uuid in list(self.datastore.data['watching'].keys()) and self.datastore.data['watching'][uuid].get('url'):
 | 
			
		||||
@@ -268,7 +268,6 @@ class update_worker(threading.Thread):
 | 
			
		||||
                    watch = self.datastore.data['watching'].get(uuid)
 | 
			
		||||
 | 
			
		||||
                    logger.info(f"Processing watch UUID {uuid} Priority {queued_item_data.priority} URL {watch['url']}")
 | 
			
		||||
                    now = time.time()
 | 
			
		||||
 | 
			
		||||
                    try:
 | 
			
		||||
                        # Processor is what we are using for detecting the "Change"
 | 
			
		||||
@@ -288,6 +287,10 @@ class update_worker(threading.Thread):
 | 
			
		||||
 | 
			
		||||
                        update_handler.call_browser()
 | 
			
		||||
 | 
			
		||||
                        # In reality, the actual time of when the change was detected could be a few seconds after this
 | 
			
		||||
                        # For example it should include when the page stopped rendering if using a playwright/chrome type fetch
 | 
			
		||||
                        fetch_start_time = time.time()
 | 
			
		||||
 | 
			
		||||
                        changed_detected, update_obj, contents = update_handler.run_changedetection(watch=watch)
 | 
			
		||||
 | 
			
		||||
                        # Re #342
 | 
			
		||||
@@ -512,7 +515,7 @@ class update_worker(threading.Thread):
 | 
			
		||||
 | 
			
		||||
                    if not self.datastore.data['watching'].get(uuid):
 | 
			
		||||
                        continue
 | 
			
		||||
                    #
 | 
			
		||||
 | 
			
		||||
                    # Different exceptions mean that we may or may not want to bump the snapshot, trigger notifications etc
 | 
			
		||||
                    if process_changedetection_results:
 | 
			
		||||
 | 
			
		||||
@@ -525,8 +528,6 @@ class update_worker(threading.Thread):
 | 
			
		||||
                                except Exception as e:
 | 
			
		||||
                                    logger.warning(f"UUID: {uuid} Extract <title> as watch title was enabled, but couldn't find a <title>.")
 | 
			
		||||
 | 
			
		||||
                        # Now update after running everything
 | 
			
		||||
                        timestamp = round(time.time())
 | 
			
		||||
                        try:
 | 
			
		||||
                            self.datastore.update_watch(uuid=uuid, update_obj=update_obj)
 | 
			
		||||
 | 
			
		||||
@@ -542,24 +543,28 @@ class update_worker(threading.Thread):
 | 
			
		||||
 | 
			
		||||
                                # 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 watch.newest_history_key and int(timestamp) == int(watch.newest_history_key):
 | 
			
		||||
                                # @also - the keys are one per second at the most (for now)
 | 
			
		||||
                                if watch.newest_history_key and int(fetch_start_time) == int(watch.newest_history_key):
 | 
			
		||||
                                    logger.warning(
 | 
			
		||||
                                        f"Timestamp {timestamp} already exists, waiting 1 seconds so we have a unique key in history.txt")
 | 
			
		||||
                                    timestamp = str(int(timestamp) + 1)
 | 
			
		||||
                                        f"Timestamp {fetch_start_time} already exists, waiting 1 seconds so we have a unique key in history.txt")
 | 
			
		||||
                                    fetch_start_time += 1
 | 
			
		||||
                                    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
                                watch.save_history_text(contents=contents,
 | 
			
		||||
                                                        timestamp=timestamp,
 | 
			
		||||
                                                        timestamp=int(fetch_start_time),
 | 
			
		||||
                                                        snapshot_id=update_obj.get('previous_md5', 'none'))
 | 
			
		||||
 | 
			
		||||
                                if update_handler.fetcher.content:
 | 
			
		||||
                                    watch.save_last_fetched_html(contents=update_handler.fetcher.content, timestamp=timestamp)
 | 
			
		||||
 | 
			
		||||
                                empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False)
 | 
			
		||||
                                if update_handler.fetcher.content or (not update_handler.fetcher.content and empty_pages_are_a_change):
 | 
			
		||||
                                    # attribute .last_changed is then based on this data
 | 
			
		||||
                                    watch.save_last_fetched_html(contents=update_handler.fetcher.content, timestamp=int(fetch_start_time))
 | 
			
		||||
 | 
			
		||||
                                # Notifications should only trigger on the second time (first time, we gather the initial snapshot)
 | 
			
		||||
                                if watch.history_n >= 2:
 | 
			
		||||
                                    logger.info(f"Change detected in UUID {uuid} - {watch['url']}")
 | 
			
		||||
                                    if not watch.get('notification_muted'):
 | 
			
		||||
                                        # @todo only run this if notifications exist
 | 
			
		||||
                                        self.send_content_changed_notification(watch_uuid=uuid)
 | 
			
		||||
 | 
			
		||||
                        except Exception as e:
 | 
			
		||||
@@ -581,15 +586,15 @@ class update_worker(threading.Thread):
 | 
			
		||||
                    except Exception as e:
 | 
			
		||||
                        pass
 | 
			
		||||
 | 
			
		||||
                    self.datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - now, 3),
 | 
			
		||||
                                                                       'last_checked': round(time.time()),
 | 
			
		||||
                    self.datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3),
 | 
			
		||||
                                                                       'last_checked': int(fetch_start_time),
 | 
			
		||||
                                                                       'check_count': count
 | 
			
		||||
                                                                       })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
                self.current_uuid = None  # Done
 | 
			
		||||
                self.q.task_done()
 | 
			
		||||
                logger.debug(f"Watch {uuid} done in {time.time()-now:.2f}s")
 | 
			
		||||
                logger.debug(f"Watch {uuid} done in {time.time()-fetch_start_time:.2f}s")
 | 
			
		||||
 | 
			
		||||
                # Give the CPU time to interrupt
 | 
			
		||||
                time.sleep(0.1)
 | 
			
		||||
 
 | 
			
		||||
@@ -12,9 +12,6 @@ services:
 | 
			
		||||
  #    environment:
 | 
			
		||||
  #        Default listening port, can also be changed with the -p option
 | 
			
		||||
  #      - PORT=5000
 | 
			
		||||
 | 
			
		||||
  #      - PUID=1000
 | 
			
		||||
  #      - PGID=1000
 | 
			
		||||
  #
 | 
			
		||||
  #        Log levels are in descending order. (TRACE is the most detailed one)
 | 
			
		||||
  #        Log output levels: TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL
 | 
			
		||||
 
 | 
			
		||||
@@ -35,7 +35,7 @@ dnspython==2.6.1 # related to eventlet fixes
 | 
			
		||||
# jq not available on Windows so must be installed manually
 | 
			
		||||
 | 
			
		||||
# Notification library
 | 
			
		||||
apprise==1.9.0
 | 
			
		||||
apprise==1.9.2
 | 
			
		||||
 | 
			
		||||
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
 | 
			
		||||
# use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814
 | 
			
		||||
@@ -95,3 +95,8 @@ babel
 | 
			
		||||
# Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096
 | 
			
		||||
greenlet >= 3.0.3
 | 
			
		||||
 | 
			
		||||
# Pinned or it causes problems with flask_expects_json which seems unmaintained
 | 
			
		||||
referencing==0.35.1
 | 
			
		||||
 | 
			
		||||
# Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !)
 | 
			
		||||
tzdata
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user