diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..14fba462a467643c1276438132ea780fc2494981
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,63 @@
+# Git
+.git/
+.gitignore
+
+# GitHub
+.github/
+
+# Byte-compiled / optimized / DLL files
+**/__pycache__
+**/*.py[cod]
+
+# Caches
+.mypy_cache/
+.pytest_cache/
+.ruff_cache/
+
+# Distribution / packaging
+build/
+dist/
+*.egg-info*
+
+# Virtual environment
+.env
+.venv/
+venv/
+
+# IntelliJ IDEA
+.idea/
+
+# Visual Studio
+.vscode/
+
+# Test and development files
+test-datastore/
+tests/
+docs/
+*.md
+!README.md
+
+# Temporary and log files
+*.log
+*.tmp
+tmp/
+temp/
+
+# Training data and large files
+train-data/
+works-data/
+
+# Container files
+Dockerfile*
+docker-compose*.yml
+.dockerignore
+
+# Development certificates and keys
+*.pem
+*.key
+*.crt
+profile_output.prof
+
+# Large binary files that shouldn't be in container
+*.pdf
+chrome.json
\ No newline at end of file
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4f38c8f25059606bd0dfb3944f0e9dd924bcdfa9
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+github: dgtlmoon
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000000000000000000000000000000000000..0bdf52f52510b1ec99785c5bfd33433ad52340ca
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,62 @@
+---
+name: Bug report
+about: Create a bug report, if you don't follow this template, your report will be DELETED
+title: ''
+labels: 'triage'
+assignees: 'dgtlmoon'
+
+---
+
+**DO NOT USE THIS FORM TO REPORT THAT A PARTICULAR WEBSITE IS NOT SCRAPING/WATCHING AS EXPECTED**
+
+This form is only for direct bugs and feature requests todo directly with the software.
+
+Please report watched websites (full URL and _any_ settings) that do not work with changedetection.io as expected [**IN THE DISCUSSION FORUMS**](https://github.com/dgtlmoon/changedetection.io/discussions) or your report will be deleted
+
+CONSIDER TAKING OUT A SUBSCRIPTION FOR A SMALL PRICE PER MONTH, YOU GET THE BENEFIT OF USING OUR PAID PROXIES AND FURTHERING THE DEVELOPMENT OF CHANGEDETECTION.IO
+
+THANK YOU
+
+
+
+
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**Version**
+*Exact version* in the top right area: 0....
+
+**How did you install?**
+
+Docker, Pip, from source directly etc
+
+**To Reproduce**
+
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+! ALWAYS INCLUDE AN EXAMPLE URL WHERE IT IS POSSIBLE TO RE-CREATE THE ISSUE - USE THE 'SHARE WATCH' FEATURE AND PASTE IN THE SHARE-LINK!
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+ - Browser [e.g. chrome, safari]
+ - Version [e.g. 22]
+
+**Smartphone (please complete the following information):**
+ - Device: [e.g. iPhone6]
+ - OS: [e.g. iOS8.1]
+ - Browser [e.g. stock browser, safari]
+ - Version [e.g. 22]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000000000000000000000000000000000000..6f50d85f1889069782992b3c2408b176f63b9169
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,23 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: '[feature]'
+labels: 'enhancement'
+assignees: ''
+
+---
+**Version and OS**
+For example, 0.123 on linux/docker
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe the use-case and give concrete real-world examples**
+Attach any HTML/JSON, give links to sites, screenshots etc, we are not mind readers
+
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000000000000000000000000000000000..413ce4b180441d68ecf45c85f65b0f9fbba43744
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,14 @@
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: "weekly"
+ "caronc/apprise":
+ versioning-strategy: "increase"
+ schedule:
+ interval: "daily"
+ groups:
+ all:
+ patterns:
+ - "*"
diff --git a/.github/test/Dockerfile-alpine b/.github/test/Dockerfile-alpine
new file mode 100644
index 0000000000000000000000000000000000000000..132391671f50bc4359e4dc16715778ce1387a006
--- /dev/null
+++ b/.github/test/Dockerfile-alpine
@@ -0,0 +1,34 @@
+# Taken from https://github.com/linuxserver/docker-changedetection.io/blob/main/Dockerfile
+# Test that we can still build on Alpine (musl modified libc https://musl.libc.org/)
+# Some packages wont install via pypi because they dont have a wheel available under this architecture.
+
+FROM ghcr.io/linuxserver/baseimage-alpine:3.21
+ENV PYTHONUNBUFFERED=1
+
+COPY requirements.txt /requirements.txt
+
+RUN \
+ apk add --update --no-cache --virtual=build-dependencies \
+ build-base \
+ cargo \
+ git \
+ jpeg-dev \
+ libc-dev \
+ libffi-dev \
+ libxslt-dev \
+ openssl-dev \
+ python3-dev \
+ zip \
+ zlib-dev && \
+ apk add --update --no-cache \
+ libjpeg \
+ libxslt \
+ nodejs \
+ poppler-utils \
+ python3 && \
+ echo "**** pip3 install test of changedetection.io ****" && \
+ 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
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5394090d72944a44e093595a3b41607bbc296473
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,62 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+ schedule:
+ - cron: '27 9 * * 4'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'javascript', 'python' ]
+ # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
+ # Learn more:
+ # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+ # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ # ℹ️ Command-line programs to run using the OS shell.
+ # 📚 https://git.io/JvXDl
+
+ # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
+ # and modify them (or add more) to build your code if your project
+ # uses a compiled language
+
+ #- run: |
+ # make bootstrap
+ # make release
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d079f28bd5d94920956068f38d5da33b8d58f2b7
--- /dev/null
+++ b/.github/workflows/containers.yml
@@ -0,0 +1,144 @@
+name: Build and push containers
+
+on:
+ # Automatically triggered by a testing workflow passing, but this is only checked when it lands in the `master`/default branch
+# workflow_run:
+# workflows: ["ChangeDetection.io Test"]
+# branches: [master]
+# tags: ['0.*']
+# types: [completed]
+
+ # Or a new tagged release
+ release:
+ types: [published, edited]
+
+ push:
+ branches:
+ - master
+
+jobs:
+ metadata:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Show metadata
+ run: |
+ echo SHA ${{ github.sha }}
+ echo github.ref: ${{ github.ref }}
+ echo github_ref: $GITHUB_REF
+ echo Event name: ${{ github.event_name }}
+ echo Ref ${{ github.ref }}
+ echo c: ${{ github.event.workflow_run.conclusion }}
+ echo r: ${{ github.event.workflow_run }}
+ echo tname: "${{ github.event.release.tag_name }}"
+ echo headbranch: -${{ github.event.workflow_run.head_branch }}-
+ set
+
+ build-push-containers:
+ runs-on: ubuntu-latest
+ # If the testing workflow has a success, then we build to :latest
+ # Or if we are in a tagged release scenario.
+ if: ${{ github.event.workflow_run.conclusion == 'success' }} || ${{ github.event.release.tag_name }} != ''
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install flake8 pytest
+ if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+
+ - name: Create release metadata
+ run: |
+ # COPY'ed by Dockerfile into changedetectionio/ of the image, then read by the server in store.py
+ echo ${{ github.sha }} > changedetectionio/source.txt
+ echo ${{ github.ref }} > changedetectionio/tag.txt
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ with:
+ image: tonistiigi/binfmt:latest
+ platforms: all
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Login to Docker Hub Container Registry
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+
+ - name: Set up Docker Buildx
+ id: buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ install: true
+ version: latest
+ driver-opts: image=moby/buildkit:master
+
+ # master branch -> :dev container tag
+ - name: Build and push :dev
+ id: docker_build
+ if: ${{ github.ref }} == "refs/heads/master"
+ uses: docker/build-push-action@v6
+ with:
+ context: ./
+ file: ./Dockerfile
+ push: true
+ tags: |
+ ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:dev,ghcr.io/${{ github.repository }}:dev
+ platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+# Looks like this was disabled
+# provenance: false
+
+ # A new tagged release is required, which builds :tag and :latest
+ - name: Debug release info
+ if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
+ run: |
+ echo "Release tag: ${{ github.event.release.tag_name }}"
+ echo "Github ref: ${{ github.ref }}"
+ echo "Github ref name: ${{ github.ref_name }}"
+
+ - 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}},value=${{ github.event.release.tag_name }}
+ type=semver,pattern={{major}}.{{minor}},value=${{ github.event.release.tag_name }}
+ type=semver,pattern={{major}},value=${{ github.event.release.tag_name }}
+ type=raw,value=latest
+
+ - name: Build and push :tag
+ id: docker_build_tag_release
+ if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')
+ uses: docker/build-push-action@v6
+ with:
+ context: ./
+ file: ./Dockerfile
+ push: true
+ 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
+# Looks like this was disabled
+# provenance: false
+
+ - name: Image digest
+ run: echo step SHA ${{ steps.vars.outputs.sha_short }} tag ${{steps.vars.outputs.tag}} branch ${{steps.vars.outputs.branch}} digest ${{ steps.docker_build.outputs.digest }}
+
diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4210c9b5756b85202ffa5021a3c7e36637c99962
--- /dev/null
+++ b/.github/workflows/pypi-release.yml
@@ -0,0 +1,80 @@
+name: Publish Python 🐍distribution 📦 to PyPI and TestPyPI
+
+on: push
+jobs:
+ build:
+ name: Build distribution 📦
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ - name: Install pypa/build
+ run: >-
+ python3 -m
+ pip install
+ build
+ --user
+ - name: Build a binary wheel and a source tarball
+ run: python3 -m build
+ - name: Store the distribution packages
+ uses: actions/upload-artifact@v4
+ with:
+ name: python-package-distributions
+ path: dist/
+
+
+ test-pypi-package:
+ name: Test the built 📦 package works basically.
+ runs-on: ubuntu-latest
+ needs:
+ - build
+ steps:
+ - name: Download all the dists
+ uses: actions/download-artifact@v4
+ with:
+ name: python-package-distributions
+ path: dist/
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ - name: Test that the basic pip built package runs without error
+ run: |
+ set -ex
+ ls -alR
+
+ # Find and install the first .whl file
+ find dist -type f -name "*.whl" -exec pip3 install {} \; -quit
+ changedetection.io -d /tmp -p 10000 &
+
+ sleep 3
+ curl --retry-connrefused --retry 6 http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null
+ curl --retry-connrefused --retry 6 http://127.0.0.1:10000/ >/dev/null
+ killall changedetection.io
+
+
+ publish-to-pypi:
+ name: >-
+ Publish Python 🐍 distribution 📦 to PyPI
+ if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes
+ needs:
+ - test-pypi-package
+ runs-on: ubuntu-latest
+ environment:
+ name: release
+ url: https://pypi.org/p/changedetection.io
+ permissions:
+ id-token: write # IMPORTANT: mandatory for trusted publishing
+
+ steps:
+ - name: Download all the dists
+ uses: actions/download-artifact@v4
+ with:
+ name: python-package-distributions
+ path: dist/
+ - name: Publish distribution 📦 to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/test-container-build.yml b/.github/workflows/test-container-build.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f9395df28bf722fe3920c07c008811543490ab10
--- /dev/null
+++ b/.github/workflows/test-container-build.yml
@@ -0,0 +1,80 @@
+name: ChangeDetection.io Container Build Test
+
+# Triggers the workflow on push or pull request events
+
+# This line doesnt work, even tho it is the documented one
+#on: [push, pull_request]
+
+on:
+ push:
+ paths:
+ - requirements.txt
+ - Dockerfile
+ - .github/workflows/*
+ - .github/test/Dockerfile*
+
+ pull_request:
+ paths:
+ - requirements.txt
+ - Dockerfile
+ - .github/workflows/*
+ - .github/test/Dockerfile*
+
+ # Changes to requirements.txt packages and Dockerfile may or may not always be compatible with arm etc, so worth testing
+ # @todo: some kind of path filter for requirements.txt and Dockerfile
+jobs:
+ builder:
+ name: Build ${{ matrix.platform }} (${{ matrix.dockerfile == './Dockerfile' && 'main' || 'alpine' }})
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ # Main Dockerfile platforms
+ - platform: linux/amd64
+ dockerfile: ./Dockerfile
+ - platform: linux/arm64
+ dockerfile: ./Dockerfile
+ - platform: linux/arm/v7
+ dockerfile: ./Dockerfile
+ - platform: linux/arm/v8
+ dockerfile: ./Dockerfile
+ - platform: linux/arm64/v8
+ dockerfile: ./Dockerfile
+ # Alpine Dockerfile platforms (musl via alpine check)
+ - platform: linux/amd64
+ dockerfile: ./.github/test/Dockerfile-alpine
+ - platform: linux/arm64
+ dockerfile: ./.github/test/Dockerfile-alpine
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+
+ # Just test that the build works, some libraries won't compile on ARM/rPi etc
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ with:
+ image: tonistiigi/binfmt:latest
+ platforms: all
+
+ - name: Set up Docker Buildx
+ id: buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ install: true
+ version: latest
+ driver-opts: image=moby/buildkit:master
+
+ - name: Test that the docker containers can build (${{ matrix.platform }} - ${{ matrix.dockerfile }})
+ id: docker_build
+ uses: docker/build-push-action@v6
+ # https://github.com/docker/build-push-action#customizing
+ with:
+ context: ./
+ file: ${{ matrix.dockerfile }}
+ platforms: ${{ matrix.platform }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
diff --git a/.github/workflows/test-only.yml b/.github/workflows/test-only.yml
new file mode 100644
index 0000000000000000000000000000000000000000..db5c3fbdf290127e3676862094dd8ba4fe937893
--- /dev/null
+++ b/.github/workflows/test-only.yml
@@ -0,0 +1,44 @@
+name: ChangeDetection.io App Test
+
+# Triggers the workflow on push or pull request events
+on: [push, pull_request]
+
+jobs:
+ lint-code:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Lint with Ruff
+ run: |
+ pip install ruff
+ # Check for syntax errors and undefined names
+ ruff check . --select E9,F63,F7,F82
+ # Complete check with errors treated as warnings
+ ruff check . --exit-zero
+
+ test-application-3-10:
+ needs: lint-code
+ uses: ./.github/workflows/test-stack-reusable-workflow.yml
+ with:
+ python-version: '3.10'
+
+
+ test-application-3-11:
+ needs: lint-code
+ uses: ./.github/workflows/test-stack-reusable-workflow.yml
+ with:
+ python-version: '3.11'
+
+ test-application-3-12:
+ needs: lint-code
+ uses: ./.github/workflows/test-stack-reusable-workflow.yml
+ with:
+ python-version: '3.12'
+ skip-pypuppeteer: true
+
+ test-application-3-13:
+ needs: lint-code
+ uses: ./.github/workflows/test-stack-reusable-workflow.yml
+ with:
+ python-version: '3.13'
+ skip-pypuppeteer: true
\ No newline at end of file
diff --git a/.github/workflows/test-stack-reusable-workflow.yml b/.github/workflows/test-stack-reusable-workflow.yml
new file mode 100644
index 0000000000000000000000000000000000000000..59125daf00b6f40df2fac3552f15c9d9452ef2d7
--- /dev/null
+++ b/.github/workflows/test-stack-reusable-workflow.yml
@@ -0,0 +1,240 @@
+name: ChangeDetection.io App Test
+
+on:
+ workflow_call:
+ inputs:
+ python-version:
+ description: "Python version to use"
+ required: true
+ type: string
+ default: "3.11"
+ skip-pypuppeteer:
+ description: "Skip PyPuppeteer (not supported in 3.11/3.12)"
+ required: false
+ type: boolean
+ default: false
+
+jobs:
+ test-application:
+ runs-on: ubuntu-latest
+ env:
+ PYTHON_VERSION: ${{ inputs.python-version }}
+ steps:
+ - uses: actions/checkout@v4
+
+ # Mainly just for link/flake8
+ - name: Set up Python ${{ env.PYTHON_VERSION }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Build changedetection.io container for testing under Python ${{ env.PYTHON_VERSION }}
+ run: |
+ echo "---- Building for Python ${{ env.PYTHON_VERSION }} -----"
+ # Build a changedetection.io container and start testing inside
+ docker build --build-arg PYTHON_VERSION=${{ env.PYTHON_VERSION }} --build-arg LOGGER_LEVEL=TRACE -t test-changedetectionio .
+ # Debug info
+ docker run test-changedetectionio bash -c 'pip list'
+
+ - name: We should be Python ${{ env.PYTHON_VERSION }} ...
+ run: |
+ docker run test-changedetectionio bash -c 'python3 --version'
+
+ - name: Spin up ancillary testable services
+ run: |
+
+ docker network create changedet-network
+
+ # Selenium
+ docker run --network changedet-network -d --hostname selenium -p 4444:4444 --rm --shm-size="2g" selenium/standalone-chrome:4
+
+ # SocketPuppetBrowser + Extra for custom browser test
+ docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser --hostname sockpuppetbrowser --rm -p 3000:3000 dgtlmoon/sockpuppetbrowser:latest
+ docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser-custom-url --hostname sockpuppetbrowser-custom-url -p 3001:3000 --rm dgtlmoon/sockpuppetbrowser:latest
+
+ - name: Spin up ancillary SMTP+Echo message test server
+ run: |
+ # Debug SMTP server/echo message back server
+ docker run --network changedet-network -d -p 11025:11025 -p 11080:11080 --hostname mailserver test-changedetectionio bash -c 'pip3 install aiosmtpd && python changedetectionio/tests/smtp/smtp-test-server.py'
+ docker ps
+
+ - name: Show docker container state and other debug info
+ run: |
+ set -x
+ echo "Running processes in docker..."
+ docker ps
+
+ - name: Run Unit Tests
+ run: |
+ # Unit tests
+ 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
+ # To change logger_level for pytest(test/conftest.py),
+ # append the docker option. e.g. '-e LOGGER_LEVEL=DEBUG'
+ docker run --name test-cdio-basic-tests --network changedet-network test-changedetectionio bash -c 'cd changedetectionio && ./run_basic_tests.sh'
+
+ # PLAYWRIGHT/NODE-> CDP
+ - name: Playwright and SocketPuppetBrowser - Specific tests in built container
+ run: |
+ # Playwright via Sockpuppetbrowser fetch
+ # tests/visualselector/test_fetch_data.py will do browser steps
+ docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
+ docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
+ docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
+ docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
+
+ - name: Playwright and SocketPuppetBrowser - Headers and requests
+ run: |
+ # Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers
+ docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio bash -c 'find .; cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py; pwd;find .'
+
+ - name: Playwright and SocketPuppetBrowser - Restock detection
+ run: |
+ # restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it
+ docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py'
+
+ # STRAIGHT TO CDP
+ - name: Pyppeteer and SocketPuppetBrowser - Specific tests in built container
+ if: ${{ inputs.skip-pypuppeteer == false }}
+ run: |
+ # Playwright via Sockpuppetbrowser fetch
+ docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
+ docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
+ docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
+ docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
+
+ - name: Pyppeteer and SocketPuppetBrowser - Headers and requests checks
+ if: ${{ inputs.skip-pypuppeteer == false }}
+ run: |
+ # Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers
+ docker run --name "changedet" --hostname changedet --rm -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
+
+ - name: Pyppeteer and SocketPuppetBrowser - Restock detection
+ if: ${{ inputs.skip-pypuppeteer == false }}
+ run: |
+ # restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it
+ docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py'
+
+ # SELENIUM
+ - name: Specific tests in built container for Selenium
+ run: |
+ # Selenium fetch
+ docker run --rm -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py'
+
+ - name: Specific tests in built container for headers and requests checks with Selenium
+ run: |
+ docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
+
+ # OTHER STUFF
+ - name: Test SMTP notification mime types
+ run: |
+ # SMTP content types - needs the 'Debug SMTP server/echo message back server' container from above
+ # "mailserver" hostname defined above
+ docker run --rm --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/smtp/test_notification_smtp.py'
+
+ # @todo Add a test via playwright/puppeteer
+ # squid with auth is tested in run_proxy_tests.sh -> tests/proxy_list/test_select_custom_proxy.py
+ - name: Test proxy squid style interaction
+ run: |
+ cd changedetectionio
+ ./run_proxy_tests.sh
+ cd ..
+
+ - name: Test proxy SOCKS5 style interaction
+ run: |
+ cd changedetectionio
+ ./run_socks_proxy_tests.sh
+ cd ..
+
+ - name: Test custom browser URL
+ run: |
+ cd changedetectionio
+ ./run_custom_browser_url_tests.sh
+ cd ..
+
+ - name: Test changedetection.io container starts+runs basically without error
+ run: |
+ docker run --name test-changedetectionio -p 5556:7860 -d test-changedetectionio
+ sleep 3
+ # Should return 0 (no error) when grep finds it
+ curl --retry-connrefused --retry 6 -s http://localhost:5556 |grep -q checkbox-uuid
+
+ # and IPv6
+ curl --retry-connrefused --retry 6 -s -g -6 "http://[::1]:5556"|grep -q checkbox-uuid
+
+ # Check whether TRACE log is enabled.
+ # Also, check whether TRACE came from STDOUT
+ docker logs test-changedetectionio 2>/dev/null | grep 'TRACE log is enabled' || exit 1
+ # Check whether DEBUG is came from STDOUT
+ docker logs test-changedetectionio 2>/dev/null | grep 'DEBUG' || exit 1
+
+ docker kill test-changedetectionio
+
+ - name: Test changedetection.io SIGTERM and SIGINT signal shutdown
+ run: |
+
+ echo SIGINT Shutdown request test
+ docker run --name sig-test -d test-changedetectionio
+ sleep 3
+ echo ">>> Sending SIGINT to sig-test container"
+ docker kill --signal=SIGINT sig-test
+ sleep 3
+ # invert the check (it should be not 0/not running)
+ docker ps
+ # check signal catch(STDERR) log. Because of
+ # changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level)
+ docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGINT' || exit 1
+ test -z "`docker ps|grep sig-test`"
+ if [ $? -ne 0 ]
+ then
+ echo "Looks like container was running when it shouldnt be"
+ docker ps
+ exit 1
+ fi
+
+ # @todo - scan the container log to see the right "graceful shutdown" text exists
+ docker rm sig-test
+
+ echo SIGTERM Shutdown request test
+ docker run --name sig-test -d test-changedetectionio
+ sleep 3
+ echo ">>> Sending SIGTERM to sig-test container"
+ docker kill --signal=SIGTERM sig-test
+ sleep 3
+ # invert the check (it should be not 0/not running)
+ docker ps
+ # check signal catch(STDERR) log. Because of
+ # changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level)
+ docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGTERM' || exit 1
+ test -z "`docker ps|grep sig-test`"
+ if [ $? -ne 0 ]
+ then
+ echo "Looks like container was running when it shouldnt be"
+ docker ps
+ exit 1
+ fi
+
+ # @todo - scan the container log to see the right "graceful shutdown" text exists
+ docker rm sig-test
+
+ - name: Dump container log
+ if: always()
+ run: |
+ mkdir output-logs
+ docker logs test-cdio-basic-tests > output-logs/test-cdio-basic-tests-stdout-${{ env.PYTHON_VERSION }}.txt
+ docker logs test-cdio-basic-tests 2> output-logs/test-cdio-basic-tests-stderr-${{ env.PYTHON_VERSION }}.txt
+
+ - name: Store everything including test-datastore
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }}
+ path: .
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..dd2bcc3dfd38834cf18fed08a8eb2aadd91ed8ef
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+# Byte-compiled / optimized / DLL files
+**/__pycache__
+**/*.py[cod]
+
+# Caches
+.mypy_cache/
+.pytest_cache/
+.ruff_cache/
+
+# Distribution / packaging
+build/
+dist/
+*.egg-info*
+
+# Virtual environment
+.env
+.venv/
+venv/
+.python-version
+
+# IDEs
+.idea
+.vscode/settings.json
+
+# Datastore files
+datastore/
+test-datastore/
+
+# Memory consumption log
+test-memory.log
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..cb387e444b6890ebbb8de97fdeb776a89313331f
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,9 @@
+repos:
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.11.2
+ hooks:
+ # Lint (and apply safe fixes)
+ - id: ruff
+ args: [--fix]
+ # Fomrat
+ - id: ruff-format
diff --git a/.ruff.toml b/.ruff.toml
new file mode 100644
index 0000000000000000000000000000000000000000..a58e4766edabb353936605ef8615754a29f3b4ae
--- /dev/null
+++ b/.ruff.toml
@@ -0,0 +1,48 @@
+# Minimum supported version
+target-version = "py310"
+
+# Formatting options
+line-length = 100
+indent-width = 4
+
+exclude = [
+ "__pycache__",
+ ".eggs",
+ ".git",
+ ".tox",
+ ".venv",
+ "*.egg-info",
+ "*.pyc",
+]
+
+[lint]
+# https://docs.astral.sh/ruff/rules/
+select = [
+ "B", # flake8-bugbear
+ "B9",
+ "C",
+ "E", # pycodestyle
+ "F", # Pyflakes
+ "I", # isort
+ "N", # pep8-naming
+ "UP", # pyupgrade
+ "W", # pycodestyle
+]
+ignore = [
+ "B007", # unused-loop-control-variable
+ "B909", # loop-iterator-mutation
+ "E203", # whitespace-before-punctuation
+ "E266", # multiple-leading-hashes-for-block-comment
+ "E501", # redundant-backslash
+ "F403", # undefined-local-with-import-star
+ "N802", # invalid-function-name
+ "N806", # non-lowercase-variable-in-function
+ "N815", # mixed-case-variable-in-class-scope
+]
+
+[lint.mccabe]
+max-complexity = 12
+
+[format]
+indent-style = "space"
+quote-style = "preserve"
diff --git a/COMMERCIAL_LICENCE.md b/COMMERCIAL_LICENCE.md
new file mode 100644
index 0000000000000000000000000000000000000000..fa59b2eaefe7ea2646c063c6f1da94a4f0b5c80d
--- /dev/null
+++ b/COMMERCIAL_LICENCE.md
@@ -0,0 +1,54 @@
+# Generally
+
+In any commercial activity involving 'Hosting' (as defined herein), whether in part or in full, this license must be executed and adhered to.
+
+# Commercial License Agreement
+
+This Commercial License Agreement ("Agreement") is entered into by and between Web Technologies s.r.o. here-in ("Licensor") and (your company or personal name) _____________ ("Licensee"). This Agreement sets forth the terms and conditions under which Licensor provides its software ("Software") and services to Licensee for the purpose of reselling the software either in part or full, as part of any commercial activity where the activity involves a third party.
+
+### Definition of Hosting
+
+For the purposes of this Agreement, "hosting" means making the functionality of the Program or modified version available to third parties as a service. This includes, without limitation:
+- Enabling third parties to interact with the functionality of the Program or modified version remotely through a computer network.
+- Offering a service the value of which entirely or primarily derives from the value of the Program or modified version.
+- Offering a service that accomplishes for users the primary purpose of the Program or modified version.
+
+## 1. Grant of License
+Subject to the terms and conditions of this Agreement, Licensor grants Licensee a non-exclusive, non-transferable license to install, use, and resell the Software. Licensee may:
+- Resell the Software as part of a service offering or as a standalone product.
+- Host the Software on a server and provide it as a hosted service (e.g., Software as a Service - SaaS).
+- Integrate the Software into a larger product or service that is then sold or provided for commercial purposes, where the software is used either in part or full.
+
+## 2. License Fees
+Licensee agrees to pay Licensor the license fees specified in the ordering document. License fees are due and payable as specified in the ordering document. The fees may include initial licensing costs and recurring fees based on the number of end users, instances of the Software resold, or revenue generated from the resale activities.
+
+## 3. Resale Conditions
+Licensee must comply with the following conditions when reselling the Software, whether the software is resold in part or full:
+- Provide end users with access to the source code under the same open-source license conditions as provided by Licensor.
+- Clearly state in all marketing and sales materials that the Software is provided under a commercial license from Licensor, and provide a link back to https://changedetection.io.
+- Ensure end users are aware of and agree to the terms of the commercial license prior to resale.
+- Do not sublicense or transfer the Software to third parties except as part of an authorized resale activity.
+
+## 4. Hosting and Provision of Services
+Licensee may host the Software (either in part or full) on its servers and provide it as a hosted service to end users. The following conditions apply:
+- Licensee must ensure that all hosted versions of the Software comply with the terms of this Agreement.
+- Licensee must provide Licensor with regular reports detailing the number of end users and instances of the hosted service.
+- Any modifications to the Software made by Licensee for hosting purposes must be made available to end users under the same open-source license conditions, unless agreed otherwise.
+
+## 5. Services
+Licensor will provide support and maintenance services as described in the support policy referenced in the ordering document should such an agreement be signed by all parties. Additional fees may apply for support services provided to end users resold by Licensee.
+
+## 6. Reporting and Audits
+Licensee agrees to provide Licensor with regular reports detailing the number of instances, end users, and revenue generated from the resale of the Software. Licensor reserves the right to audit Licensee’s records to ensure compliance with this Agreement.
+
+## 7. Term and Termination
+This Agreement shall commence on the effective date and continue for the period set forth in the ordering document unless terminated earlier in accordance with this Agreement. Either party may terminate this Agreement if the other party breaches any material term and fails to cure such breach within thirty (30) days after receipt of written notice.
+
+## 8. Limitation of Liability and Disclaimer of Warranty
+Executing this commercial license does not waive the Limitation of Liability or Disclaimer of Warranty as stated in the open-source LICENSE provided with the Software. The Software is provided "as is," without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software.
+
+## 9. Governing Law
+This Agreement shall be governed by and construed in accordance with the laws of the Czech Republic.
+
+## Contact Information
+For commercial licensing inquiries, please contact contact@changedetection.io and dgtlmoon@gmail.com.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000000000000000000000000000000000000..da3d99a967e4cc453b74adfa27698390ed981a84
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,9 @@
+Contributing is always welcome!
+
+I am no professional flask developer, if you know a better way that something can be done, please let me know!
+
+Otherwise, it's always best to PR into the `master` branch.
+
+Please be sure that all new functionality has a matching test!
+
+Use `pytest` to validate/test, you can run the existing tests as `pytest tests/test_notification.py` for example
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..ffc5bf7bf7d64a00c92e25c1afb53e7e26eb8930
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,87 @@
+# pip dependencies install stage
+
+ARG PYTHON_VERSION=3.11
+
+FROM python:${PYTHON_VERSION}-slim-bookworm AS builder
+
+# See `cryptography` pin comment in requirements.txt
+ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ g++ \
+ gcc \
+ libc-dev \
+ libffi-dev \
+ libjpeg-dev \
+ libssl-dev \
+ libxslt-dev \
+ make \
+ zlib1g-dev
+
+RUN mkdir /install
+WORKDIR /install
+
+COPY requirements.txt /requirements.txt
+
+# Use cache mounts and multiple wheel sources for faster ARM builds
+ENV PIP_CACHE_DIR=/tmp/pip-cache
+RUN --mount=type=cache,target=/tmp/pip-cache \
+ pip install \
+ --extra-index-url https://www.piwheels.org/simple \
+ --extra-index-url https://pypi.anaconda.org/ARM-software/simple \
+ --cache-dir=/tmp/pip-cache \
+ --target=/dependencies \
+ -r /requirements.txt
+
+# Playwright is an alternative to Selenium
+# Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing
+# https://github.com/dgtlmoon/changedetection.io/pull/1067 also musl/alpine (not supported)
+RUN --mount=type=cache,target=/tmp/pip-cache \
+ pip install \
+ --cache-dir=/tmp/pip-cache \
+ --target=/dependencies \
+ playwright~=1.48.0 \
+ || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled."
+
+# Final image stage
+FROM python:${PYTHON_VERSION}-slim-bookworm
+LABEL org.opencontainers.image.source="https://github.com/dgtlmoon/changedetection.io"
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libxslt1.1 \
+ # For presenting price amounts correctly in the restock/price detection overview
+ locales \
+ # For pdftohtml
+ poppler-utils \
+ zlib1g \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+
+# https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops
+ENV PYTHONUNBUFFERED=1
+
+RUN useradd -m appuser
+RUN mkdir -p /datastore && chown -R appuser:appuser /datastore
+
+# Re #80, sets SECLEVEL=1 in openssl.conf to allow monitoring sites with weak/old cipher suites
+RUN sed -i 's/^CipherString = .*/CipherString = DEFAULT@SECLEVEL=1/' /etc/ssl/openssl.cnf
+
+# Copy modules over to the final image and add their dir to PYTHONPATH
+COPY --from=builder /dependencies /usr/local
+ENV PYTHONPATH=/usr/local
+
+COPY changedetectionio /app/changedetectionio
+COPY changedetection.py /app/changedetection.py
+
+# Github Action test purpose(test-only.yml).
+# On production, it is effectively LOGGER_LEVEL=''.
+ARG LOGGER_LEVEL=''
+ENV LOGGER_LEVEL="$LOGGER_LEVEL"
+
+USER appuser
+WORKDIR /app
+EXPOSE 7860
+
+CMD ["python", "./changedetection.py", "-d", "/datastore"]
+
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..261eeb9e9f8b2b4b0d119366dda99c6fd7d35c64
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000000000000000000000000000000000000..950c182db6c59325834a978745c535cf844d7c74
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,24 @@
+recursive-include changedetectionio/api *
+recursive-include changedetectionio/blueprint *
+recursive-include changedetectionio/content_fetchers *
+recursive-include changedetectionio/conditions *
+recursive-include changedetectionio/model *
+recursive-include changedetectionio/notification *
+recursive-include changedetectionio/processors *
+recursive-include changedetectionio/realtime *
+recursive-include changedetectionio/static *
+recursive-include changedetectionio/templates *
+recursive-include changedetectionio/tests *
+prune changedetectionio/static/package-lock.json
+prune changedetectionio/static/styles/node_modules
+prune changedetectionio/static/styles/package-lock.json
+include changedetection.py
+include requirements.txt
+include README-pip.md
+global-exclude *.pyc
+global-exclude node_modules
+global-exclude venv
+
+global-exclude test-datastore
+global-exclude changedetection.io*dist-info
+global-exclude changedetectionio/tests/proxy_socks5/test-datastore
diff --git a/README-pip.md b/README-pip.md
new file mode 100644
index 0000000000000000000000000000000000000000..45a691b1ff490d6979eef932757fa7d1fcd1d9e1
--- /dev/null
+++ b/README-pip.md
@@ -0,0 +1,91 @@
+## Web Site Change Detection, Monitoring and Notification.
+
+Live your data-life pro-actively, track website content changes and receive notifications via Discord, Email, Slack, Telegram and 70+ more
+
+[ ](https://changedetection.io)
+
+[**Don't have time? Let us host it for you! try our extremely affordable subscription use our proxies and support!**](https://changedetection.io)
+
+### Target specific parts of the webpage using the Visual Selector tool.
+
+Available when connected to a playwright content fetcher (included as part of our subscription service)
+
+[ ](https://changedetection.io?src=pip)
+
+### Easily see what changed, examine by word, line, or individual character.
+
+[ ](https://changedetection.io?src=pip)
+
+### Perform interactive browser steps
+
+Fill in text boxes, click buttons and more, setup your changedetection scenario.
+
+Using the **Browser Steps** configuration, add basic steps before performing change detection, such as logging into websites, adding a product to a cart, accept cookie logins, entering dates and refining searches.
+
+[ ](https://changedetection.io?src=pip)
+
+After **Browser Steps** have been run, then visit the **Visual Selector** tab to refine the content you're interested in.
+Requires Playwright to be enabled.
+
+### Example use cases
+
+- Products and services have a change in pricing
+- _Out of stock notification_ and _Back In stock notification_
+- Monitor and track PDF file changes, know when a PDF file has text changes.
+- Governmental department updates (changes are often only on their websites)
+- New software releases, security advisories when you're not on their mailing list.
+- Festivals with changes
+- Discogs restock alerts and monitoring
+- Realestate listing changes
+- Know when your favourite whiskey is on sale, or other special deals are announced before anyone else
+- COVID related news from government websites
+- University/organisation news from their website
+- Detect and monitor changes in JSON API responses
+- JSON API monitoring and alerting
+- Changes in legal and other documents
+- Trigger API calls via notifications when text appears on a website
+- Glue together APIs using the JSON filter and JSON notifications
+- Create RSS feeds based on changes in web content
+- Monitor HTML source code for unexpected changes, strengthen your PCI compliance
+- You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product)
+- Get notified when certain keywords appear in Twitter search results
+- Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords.
+- Get alerts when new job positions are open on Bamboo HR and other job platforms
+- Website defacement monitoring
+- Pokémon Card Restock Tracker / Pokémon TCG Tracker
+- RegTech - stay ahead of regulatory changes, regulatory compliance
+
+_Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!_
+
+#### Key Features
+
+- Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions!
+- Target elements with xPath(1.0) and CSS Selectors, Easily monitor complex JSON with JSONPath or jq
+- Switch between fast non-JS and Chrome JS based "fetchers"
+- Track changes in PDF files (Monitor text changed in the PDF, Also monitor PDF filesize and checksums)
+- Easily specify how often a site should be checked
+- Execute JS before extracting text (Good for logging in, see examples in the UI!)
+- Override Request Headers, Specify `POST` or `GET` and other methods
+- Use the "Visual Selector" to help target specific elements
+- Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration)
+- Send a screenshot with the notification when a change is detected in the web page
+
+We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) global proxy services, Bright Data will match any first deposit up to $100 using our signup link.
+
+[Oxylabs](https://oxylabs.go2cloud.org/SH2d) is also an excellent proxy provider and well worth using, they offer Residental, ISP, Rotating and many other proxy types to suit your project.
+
+Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/
+
+```bash
+$ pip3 install changedetection.io
+```
+
+Specify a target for the _datastore path_ with `-d` (required) and a _listening port_ with `-p` (defaults to `7860`)
+
+```bash
+$ changedetection.io -d /path/to/empty/data/dir -p 7860
+```
+
+Then visit http://127.0.0.1:7860 , You should now be able to access the UI.
+
+See https://changedetection.io for more information.
diff --git a/changedetection.py b/changedetection.py
new file mode 100644
index 0000000000000000000000000000000000000000..a5400211c50776689e44ade478918db96571cf36
--- /dev/null
+++ b/changedetection.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python3
+
+# Only exists for direct CLI usage
+
+import changedetectionio
+
+if __name__ == '__main__':
+ changedetectionio.main()
diff --git a/changedetectionio/.gitignore b/changedetectionio/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..0d3c1d4eb9e6e01873cbd5d9fbd87fd316f43b57
--- /dev/null
+++ b/changedetectionio/.gitignore
@@ -0,0 +1,2 @@
+test-datastore
+package-lock.json
diff --git a/changedetectionio/PLUGIN_README.md b/changedetectionio/PLUGIN_README.md
new file mode 100644
index 0000000000000000000000000000000000000000..5c0f0d6031b43f6d83c0c3996159708d4bbb6019
--- /dev/null
+++ b/changedetectionio/PLUGIN_README.md
@@ -0,0 +1,98 @@
+# Creating Plugins for changedetection.io
+
+This document describes how to create plugins for changedetection.io. Plugins can be used to extend the functionality of the application in various ways.
+
+## Plugin Types
+
+### UI Stats Tab Plugins
+
+These plugins can add content to the Stats tab in the Edit page. This is useful for adding custom statistics or visualizations about a watch.
+
+#### Creating a UI Stats Tab Plugin
+
+1. Create a Python file in a directory that will be loaded by the plugin system.
+
+2. Use the `global_hookimpl` decorator to implement the `ui_edit_stats_extras` hook:
+
+```python
+import pluggy
+from loguru import logger
+
+global_hookimpl = pluggy.HookimplMarker("changedetectionio")
+
+@global_hookimpl
+def ui_edit_stats_extras(watch):
+ """Add custom content to the stats tab"""
+ # Calculate or retrieve your stats
+ my_stat = calculate_something(watch)
+
+ # Return HTML content as a string
+ html = f"""
+
+
My Plugin Statistics
+
My statistic: {my_stat}
+
+ """
+ return html
+```
+
+3. The HTML you return will be included in the Stats tab.
+
+## Plugin Loading
+
+Plugins can be loaded from:
+
+1. Built-in plugin directories in the codebase
+2. External packages using setuptools entry points
+
+To add a new plugin directory, modify the `plugin_dirs` dictionary in `pluggy_interface.py`.
+
+## Example Plugin
+
+Here's a simple example of a plugin that adds a word count statistic to the Stats tab:
+
+```python
+import pluggy
+from loguru import logger
+
+global_hookimpl = pluggy.HookimplMarker("changedetectionio")
+
+def count_words_in_history(watch):
+ """Count words in the latest snapshot"""
+ try:
+ if not watch.history.keys():
+ return 0
+
+ latest_key = list(watch.history.keys())[-1]
+ latest_content = watch.get_history_snapshot(latest_key)
+ return len(latest_content.split())
+ except Exception as e:
+ logger.error(f"Error counting words: {str(e)}")
+ return 0
+
+@global_hookimpl
+def ui_edit_stats_extras(watch):
+ """Add word count to the Stats tab"""
+ word_count = count_words_in_history(watch)
+
+ html = f"""
+
+
Content Analysis
+
+
+
+ Word count (latest snapshot)
+ {word_count}
+
+
+
+
+ """
+ return html
+```
+
+## Testing Your Plugin
+
+1. Place your plugin in one of the directories scanned by the plugin system
+2. Restart changedetection.io
+3. Go to the Edit page of a watch and check the Stats tab to see your content
\ No newline at end of file
diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e81fa2f5f02c45acf5016650c9ad54060b039a8d
--- /dev/null
+++ b/changedetectionio/__init__.py
@@ -0,0 +1,240 @@
+#!/usr/bin/env python3
+
+# Read more https://github.com/dgtlmoon/changedetection.io/wiki
+
+__version__ = '0.50.2'
+
+from changedetectionio.strtobool import strtobool
+from json.decoder import JSONDecodeError
+import os
+import getopt
+import platform
+import signal
+
+import sys
+
+# Eventlet completely removed - using threading mode for SocketIO
+# This provides better Python 3.12+ compatibility and eliminates eventlet/asyncio conflicts
+from changedetectionio import store
+from changedetectionio.flask_app import changedetection_app
+from loguru import logger
+
+# Only global so we can access it in the signal handler
+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):
+ name = signal.Signals(_signo).name
+ logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Fast shutdown initiated')
+
+ # Set exit flag immediately to stop all loops
+ app.config.exit.set()
+ datastore.stop_thread = True
+
+ # Shutdown workers immediately
+ try:
+ from changedetectionio import worker_handler
+ worker_handler.shutdown_workers()
+ except Exception as e:
+ logger.error(f"Error shutting down workers: {str(e)}")
+
+ # Shutdown socketio server fast
+ from changedetectionio.flask_app import socketio_server
+ if socketio_server and hasattr(socketio_server, 'shutdown'):
+ try:
+ socketio_server.shutdown()
+ except Exception as e:
+ logger.error(f"Error shutting down Socket.IO server: {str(e)}")
+
+ # Save data quickly
+ try:
+ datastore.sync_to_json()
+ logger.success('Fast sync to disk complete.')
+ except Exception as e:
+ logger.error(f"Error syncing to disk: {str(e)}")
+
+ sys.exit()
+
+def main():
+ global datastore
+ global app
+
+ datastore_path = None
+ do_cleanup = False
+ host = "0.0.0.0"
+ ipv6_enabled = False
+ port = int(os.environ.get('PORT', 7860))
+ ssl_mode = False
+
+ # On Windows, create and use a default path.
+ if os.name == 'nt':
+ datastore_path = os.path.expandvars(r'%APPDATA%\changedetection.io')
+ os.makedirs(datastore_path, exist_ok=True)
+ else:
+ # Must be absolute so that send_from_directory doesnt try to make it relative to backend/
+ datastore_path = os.path.join(os.getcwd(), "../datastore")
+
+ try:
+ opts, args = getopt.getopt(sys.argv[1:], "6Ccsd:h:p:l:", "port")
+ except getopt.GetoptError:
+ print('backend.py -s SSL enable -h [host] -p [port] -d [datastore path] -l [debug level - TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL]')
+ sys.exit(2)
+
+ create_datastore_dir = False
+
+ # Set a default logger level
+ logger_level = 'DEBUG'
+ # Set a logger level via shell env variable
+ # Used: Dockerfile for CICD
+ # To set logger level for pytest, see the app function in tests/conftest.py
+ if os.getenv("LOGGER_LEVEL"):
+ level = os.getenv("LOGGER_LEVEL")
+ logger_level = int(level) if level.isdigit() else level.upper()
+
+ for opt, arg in opts:
+ if opt == '-s':
+ ssl_mode = True
+
+ if opt == '-h':
+ host = arg
+
+ if opt == '-p':
+ port = int(arg)
+
+ if opt == '-d':
+ datastore_path = arg
+
+ if opt == '-6':
+ logger.success("Enabling IPv6 listen support")
+ ipv6_enabled = True
+
+ # Cleanup (remove text files that arent in the index)
+ if opt == '-c':
+ do_cleanup = True
+
+ # Create the datadir if it doesnt exist
+ if opt == '-C':
+ create_datastore_dir = True
+
+ if opt == '-l':
+ logger_level = int(arg) if arg.isdigit() else arg.upper()
+
+ # Without this, a logger will be duplicated
+ logger.remove()
+ try:
+ log_level_for_stdout = { 'TRACE', 'DEBUG', 'INFO', 'SUCCESS' }
+ logger.configure(handlers=[
+ {"sink": sys.stdout, "level": logger_level,
+ "filter" : lambda record: record['level'].name in log_level_for_stdout},
+ {"sink": sys.stderr, "level": logger_level,
+ "filter": lambda record: record['level'].name not in log_level_for_stdout},
+ ])
+ # Catch negative number or wrong log level name
+ except ValueError:
+ print("Available log level names: TRACE, DEBUG(default), INFO, SUCCESS,"
+ " WARNING, ERROR, CRITICAL")
+ sys.exit(2)
+
+ # isnt there some @thingy to attach to each route to tell it, that this route needs a datastore
+ app_config = {'datastore_path': datastore_path}
+
+ if not os.path.isdir(app_config['datastore_path']):
+ if create_datastore_dir:
+ os.mkdir(app_config['datastore_path'])
+ else:
+ logger.critical(
+ f"ERROR: Directory path for the datastore '{app_config['datastore_path']}'"
+ f" does not exist, cannot start, please make sure the"
+ f" directory exists or specify a directory with the -d option.\n"
+ f"Or use the -C parameter to create the directory.")
+ sys.exit(2)
+
+ try:
+ datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], version_tag=__version__)
+ except JSONDecodeError as e:
+ # Dont' start if the JSON DB looks corrupt
+ logger.critical(f"ERROR: JSON DB or Proxy List JSON at '{app_config['datastore_path']}' appears to be corrupt, aborting.")
+ logger.critical(str(e))
+ return
+
+ app = changedetection_app(app_config, datastore)
+
+ # Get the SocketIO instance from the Flask app (created in flask_app.py)
+ from changedetectionio.flask_app import socketio_server
+ global socketio
+ socketio = socketio_server
+
+ signal.signal(signal.SIGTERM, sigshutdown_handler)
+ signal.signal(signal.SIGINT, sigshutdown_handler)
+
+ # Custom signal handler for memory cleanup
+ def sigusr_clean_handler(_signo, _stack_frame):
+ from changedetectionio.gc_cleanup import memory_cleanup
+ logger.info('SIGUSR1 received: Running memory cleanup')
+ return memory_cleanup(app)
+
+ # Register the SIGUSR1 signal handler
+ # Only register the signal handler if running on Linux
+ if platform.system() == "Linux":
+ signal.signal(signal.SIGUSR1, sigusr_clean_handler)
+ else:
+ logger.info("SIGUSR1 handler only registered on Linux, skipped.")
+
+ # Go into cleanup mode
+ if do_cleanup:
+ datastore.remove_unused_snapshots()
+
+ app.config['datastore_path'] = datastore_path
+
+
+ @app.context_processor
+ def inject_template_globals():
+ return dict(right_sticky="v{}".format(datastore.data['version_tag']),
+ new_version_available=app.config['NEW_VERSION_AVAILABLE'],
+ has_password=datastore.data['settings']['application']['password'] != False,
+ socket_io_enabled=datastore.data['settings']['application']['ui'].get('socket_io_enabled', True)
+ )
+
+ # Monitored websites will not receive a Referer header when a user clicks on an outgoing link.
+ @app.after_request
+ def hide_referrer(response):
+ if strtobool(os.getenv("HIDE_REFERER", 'false')):
+ response.headers["Referrer-Policy"] = "same-origin"
+
+ return response
+
+ # Proxy sub-directory support
+ # Set environment var USE_X_SETTINGS=1 on this script
+ # And then in your proxy_pass settings
+ #
+ # proxy_set_header Host "localhost";
+ # proxy_set_header X-Forwarded-Prefix /app;
+
+
+ if os.getenv('USE_X_SETTINGS'):
+ logger.info("USE_X_SETTINGS is ENABLED")
+ from werkzeug.middleware.proxy_fix import ProxyFix
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1, x_host=1)
+
+
+ # SocketIO instance is already initialized in flask_app.py
+
+ # Launch using SocketIO run method for proper integration (if enabled)
+ if socketio_server:
+ if ssl_mode:
+ socketio.run(app, host=host, port=int(port), debug=False,
+ certfile='cert.pem', keyfile='privkey.pem', allow_unsafe_werkzeug=True)
+ else:
+ socketio.run(app, host=host, port=int(port), debug=False, allow_unsafe_werkzeug=True)
+ else:
+ # Run Flask app without Socket.IO if disabled
+ logger.info("Starting Flask app without Socket.IO server")
+ if ssl_mode:
+ app.run(host=host, port=int(port), debug=False,
+ ssl_context=('cert.pem', 'privkey.pem'))
+ else:
+ app.run(host=host, port=int(port), debug=False)
diff --git a/changedetectionio/api/Import.py b/changedetectionio/api/Import.py
new file mode 100644
index 0000000000000000000000000000000000000000..071097621ee2c26287bf2f8618366b59e17bd4ca
--- /dev/null
+++ b/changedetectionio/api/Import.py
@@ -0,0 +1,62 @@
+import os
+from changedetectionio.strtobool import strtobool
+from flask_restful import abort, Resource
+from flask import request
+import validators
+from . import auth
+
+
+class Import(Resource):
+ def __init__(self, **kwargs):
+ # datastore is a black box dependency
+ self.datastore = kwargs['datastore']
+
+ @auth.check_token
+ def post(self):
+ """
+ @api {post} /api/v1/import Import a list of watched URLs
+ @apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line.
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a"
+ @apiName Import
+ @apiGroup Watch
+ @apiSuccess (200) {List} OK List of watch UUIDs added
+ @apiSuccess (500) {String} ERR Some other error
+ """
+
+ extras = {}
+
+ if request.args.get('proxy'):
+ plist = self.datastore.proxy_list
+ if not request.args.get('proxy') in plist:
+ return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400
+ else:
+ extras['proxy'] = request.args.get('proxy')
+
+ dedupe = strtobool(request.args.get('dedupe', 'true'))
+
+ tags = request.args.get('tag')
+ tag_uuids = request.args.get('tag_uuids')
+
+ if tag_uuids:
+ tag_uuids = tag_uuids.split(',')
+
+ urls = request.get_data().decode('utf8').splitlines()
+ added = []
+ allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
+ for url in urls:
+ url = url.strip()
+ if not len(url):
+ continue
+
+ # If hosts that only contain alphanumerics are allowed ("localhost" for example)
+ if not validators.url(url, simple_host=allow_simplehost):
+ return f"Invalid or unsupported URL - {url}", 400
+
+ if dedupe and self.datastore.url_exists(url):
+ continue
+
+ new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids)
+ added.append(new_uuid)
+
+ return added
\ No newline at end of file
diff --git a/changedetectionio/api/Notifications.py b/changedetectionio/api/Notifications.py
new file mode 100644
index 0000000000000000000000000000000000000000..4ea98693aa2df8d8ffa26053e0d319ce08e4bfee
--- /dev/null
+++ b/changedetectionio/api/Notifications.py
@@ -0,0 +1,145 @@
+from flask_expects_json import expects_json
+from flask_restful import Resource
+from . import auth
+from flask_restful import abort, Resource
+from flask import request
+from . import auth
+from . import schema_create_notification_urls, schema_delete_notification_urls
+
+class Notifications(Resource):
+ def __init__(self, **kwargs):
+ # datastore is a black box dependency
+ self.datastore = kwargs['datastore']
+
+ @auth.check_token
+ def get(self):
+ """
+ @api {get} /api/v1/notifications Return Notification URL List
+ @apiDescription Return the Notification URL List from the configuration
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ HTTP/1.0 200
+ {
+ 'notification_urls': ["notification-urls-list"]
+ }
+ @apiName Get
+ @apiGroup Notifications
+ """
+
+ notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])
+
+ return {
+ 'notification_urls': notification_urls,
+ }, 200
+
+ @auth.check_token
+ @expects_json(schema_create_notification_urls)
+ def post(self):
+ """
+ @api {post} /api/v1/notifications Create Notification URLs
+ @apiDescription Add one or more notification URLs from the configuration
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/notifications/batch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
+ @apiName CreateBatch
+ @apiGroup Notifications
+ @apiSuccess (201) {Object[]} notification_urls List of added notification URLs
+ @apiError (400) {String} Invalid input
+ """
+
+ json_data = request.get_json()
+ notification_urls = json_data.get("notification_urls", [])
+
+ from wtforms import ValidationError
+ try:
+ validate_notification_urls(notification_urls)
+ except ValidationError as e:
+ return str(e), 400
+
+ added_urls = []
+
+ for url in notification_urls:
+ clean_url = url.strip()
+ added_url = self.datastore.add_notification_url(clean_url)
+ if added_url:
+ added_urls.append(added_url)
+
+ if not added_urls:
+ return "No valid notification URLs were added", 400
+
+ return {'notification_urls': added_urls}, 201
+
+ @auth.check_token
+ @expects_json(schema_create_notification_urls)
+ def put(self):
+ """
+ @api {put} /api/v1/notifications Replace Notification URLs
+ @apiDescription Replace all notification URLs with the provided list (can be empty)
+ @apiExample {curl} Example usage:
+ curl -X PUT http://localhost:7860/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
+ @apiName Replace
+ @apiGroup Notifications
+ @apiSuccess (200) {Object[]} notification_urls List of current notification URLs
+ @apiError (400) {String} Invalid input
+ """
+ json_data = request.get_json()
+ notification_urls = json_data.get("notification_urls", [])
+
+ from wtforms import ValidationError
+ try:
+ validate_notification_urls(notification_urls)
+ except ValidationError as e:
+ return str(e), 400
+
+ if not isinstance(notification_urls, list):
+ return "Invalid input format", 400
+
+ clean_urls = [url.strip() for url in notification_urls if isinstance(url, str)]
+ self.datastore.data['settings']['application']['notification_urls'] = clean_urls
+ self.datastore.needs_write = True
+
+ return {'notification_urls': clean_urls}, 200
+
+ @auth.check_token
+ @expects_json(schema_delete_notification_urls)
+ def delete(self):
+ """
+ @api {delete} /api/v1/notifications Delete Notification URLs
+ @apiDescription Deletes one or more notification URLs from the configuration
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/notifications -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
+ @apiParam {String[]} notification_urls The notification URLs to delete.
+ @apiName Delete
+ @apiGroup Notifications
+ @apiSuccess (204) {String} OK Deleted
+ @apiError (400) {String} No matching notification URLs found.
+ """
+
+ json_data = request.get_json()
+ urls_to_delete = json_data.get("notification_urls", [])
+ if not isinstance(urls_to_delete, list):
+ abort(400, message="Expected a list of notification URLs.")
+
+ notification_urls = self.datastore.data['settings']['application'].get('notification_urls', [])
+ deleted = []
+
+ for url in urls_to_delete:
+ clean_url = url.strip()
+ if clean_url in notification_urls:
+ notification_urls.remove(clean_url)
+ deleted.append(clean_url)
+
+ if not deleted:
+ abort(400, message="No matching notification URLs found.")
+
+ self.datastore.data['settings']['application']['notification_urls'] = notification_urls
+ self.datastore.needs_write = True
+
+ return 'OK', 204
+
+def validate_notification_urls(notification_urls):
+ from changedetectionio.forms import ValidateAppRiseServers
+ validator = ValidateAppRiseServers()
+ class DummyForm: pass
+ dummy_form = DummyForm()
+ field = type("Field", (object,), {"data": notification_urls, "gettext": lambda self, x: x})()
+ validator(dummy_form, field)
\ No newline at end of file
diff --git a/changedetectionio/api/Search.py b/changedetectionio/api/Search.py
new file mode 100644
index 0000000000000000000000000000000000000000..0e1714f81705eb704593dbbb9cea7274479c097b
--- /dev/null
+++ b/changedetectionio/api/Search.py
@@ -0,0 +1,51 @@
+from flask_restful import Resource, abort
+from flask import request
+from . import auth
+
+class Search(Resource):
+ def __init__(self, **kwargs):
+ # datastore is a black box dependency
+ self.datastore = kwargs['datastore']
+
+ @auth.check_token
+ def get(self):
+ """
+ @api {get} /api/v1/search Search for watches
+ @apiDescription Search watches by URL or title text
+ @apiExample {curl} Example usage:
+ curl "http://localhost:7860/api/v1/search?q=https://example.com/page1" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ curl "http://localhost:7860/api/v1/search?q=https://example.com/page1?tag=Favourites" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ curl "http://localhost:7860/api/v1/search?q=https://example.com?partial=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ @apiName Search
+ @apiGroup Watch Management
+ @apiQuery {String} q Search query to match against watch URLs and titles
+ @apiQuery {String} [tag] Optional name of tag to limit results (name not UUID)
+ @apiQuery {String} [partial] Allow partial matching of URL query
+ @apiSuccess (200) {Object} JSON Object containing matched watches
+ """
+ query = request.args.get('q', '').strip()
+ tag_limit = request.args.get('tag', '').strip()
+ from changedetectionio.strtobool import strtobool
+ partial = bool(strtobool(request.args.get('partial', '0'))) if 'partial' in request.args else False
+
+ # Require a search query
+ if not query:
+ abort(400, message="Search query 'q' parameter is required")
+
+ # Use the search function from the datastore
+ matching_uuids = self.datastore.search_watches_for_url(query=query, tag_limit=tag_limit, partial=partial)
+
+ # Build the response with watch details
+ results = {}
+ for uuid in matching_uuids:
+ watch = self.datastore.data['watching'].get(uuid)
+ results[uuid] = {
+ 'last_changed': watch.last_changed,
+ 'last_checked': watch['last_checked'],
+ 'last_error': watch['last_error'],
+ 'title': watch['title'],
+ 'url': watch['url'],
+ 'viewed': watch.viewed
+ }
+
+ return results, 200
\ No newline at end of file
diff --git a/changedetectionio/api/SystemInfo.py b/changedetectionio/api/SystemInfo.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6cb803fc0d12a289ef08969f909e7d90d759e11
--- /dev/null
+++ b/changedetectionio/api/SystemInfo.py
@@ -0,0 +1,54 @@
+from flask_restful import Resource
+from . import auth
+
+
+class SystemInfo(Resource):
+ def __init__(self, **kwargs):
+ # datastore is a black box dependency
+ self.datastore = kwargs['datastore']
+ self.update_q = kwargs['update_q']
+
+ @auth.check_token
+ def get(self):
+ """
+ @api {get} /api/v1/systeminfo Return system info
+ @apiDescription Return some info about the current system state
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ HTTP/1.0 200
+ {
+ 'queue_size': 10 ,
+ 'overdue_watches': ["watch-uuid-list"],
+ 'uptime': 38344.55,
+ 'watch_count': 800,
+ 'version': "0.40.1"
+ }
+ @apiName Get Info
+ @apiGroup System Information
+ """
+ import time
+ overdue_watches = []
+
+ # Check all watches and report which have not been checked but should have been
+
+ for uuid, watch in self.datastore.data.get('watching', {}).items():
+ # see if now - last_checked is greater than the time that should have been
+ # this is not super accurate (maybe they just edited it) but better than nothing
+ t = watch.threshold_seconds()
+ if not t:
+ # Use the system wide default
+ t = self.datastore.threshold_seconds
+
+ time_since_check = time.time() - watch.get('last_checked')
+
+ # Allow 5 minutes of grace time before we decide it's overdue
+ if time_since_check - (5 * 60) > t:
+ overdue_watches.append(uuid)
+ from changedetectionio import __version__ as main_version
+ return {
+ 'queue_size': self.update_q.qsize(),
+ 'overdue_watches': overdue_watches,
+ 'uptime': round(time.time() - self.datastore.start_time, 2),
+ 'watch_count': len(self.datastore.data.get('watching', {})),
+ 'version': main_version
+ }, 200
\ No newline at end of file
diff --git a/changedetectionio/api/Tags.py b/changedetectionio/api/Tags.py
new file mode 100644
index 0000000000000000000000000000000000000000..d04d7590900ccc35ccefac274f65465fe9d60956
--- /dev/null
+++ b/changedetectionio/api/Tags.py
@@ -0,0 +1,156 @@
+from flask_expects_json import expects_json
+from flask_restful import abort, Resource
+from flask import request
+from . import auth
+
+# Import schemas from __init__.py
+from . import schema_tag, schema_create_tag, schema_update_tag
+
+
+class Tag(Resource):
+ def __init__(self, **kwargs):
+ # datastore is a black box dependency
+ self.datastore = kwargs['datastore']
+
+ # Get information about a single tag
+ # curl http://localhost:7860/api/v1/tag/
+ @auth.check_token
+ def get(self, uuid):
+ """
+ @api {get} /api/v1/tag/:uuid Single tag - get data or toggle notification muting.
+ @apiDescription Retrieve tag information and set notification_muted status
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ curl "http://localhost:7860/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=muted" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ @apiName Tag
+ @apiGroup Tag
+ @apiParam {uuid} uuid Tag unique ID.
+ @apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state
+ @apiSuccess (200) {String} OK When muted operation OR full JSON object of the tag
+ @apiSuccess (200) {JSON} TagJSON JSON Full JSON object of the tag
+ """
+ from copy import deepcopy
+ tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid))
+ if not tag:
+ abort(404, message=f'No tag exists with the UUID of {uuid}')
+
+ if request.args.get('muted', '') == 'muted':
+ self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = True
+ return "OK", 200
+ elif request.args.get('muted', '') == 'unmuted':
+ self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = False
+ return "OK", 200
+
+ return tag
+
+ @auth.check_token
+ def delete(self, uuid):
+ """
+ @api {delete} /api/v1/tag/:uuid Delete a tag and remove it from all watches
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ @apiParam {uuid} uuid Tag unique ID.
+ @apiName DeleteTag
+ @apiGroup Tag
+ @apiSuccess (200) {String} OK Was deleted
+ """
+ if not self.datastore.data['settings']['application']['tags'].get(uuid):
+ abort(400, message='No tag exists with the UUID of {}'.format(uuid))
+
+ # Delete the tag, and any tag reference
+ del self.datastore.data['settings']['application']['tags'][uuid]
+
+ # Remove tag from all watches
+ for watch_uuid, watch in self.datastore.data['watching'].items():
+ if watch.get('tags') and uuid in watch['tags']:
+ watch['tags'].remove(uuid)
+
+ return 'OK', 204
+
+ @auth.check_token
+ @expects_json(schema_update_tag)
+ def put(self, uuid):
+ """
+ @api {put} /api/v1/tag/:uuid Update tag information
+ @apiExample {curl} Example usage:
+ Update (PUT)
+ curl http://localhost:7860/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"title": "New Tag Title"}'
+
+ @apiDescription Updates an existing tag using JSON
+ @apiParam {uuid} uuid Tag unique ID.
+ @apiName UpdateTag
+ @apiGroup Tag
+ @apiSuccess (200) {String} OK Was updated
+ @apiSuccess (500) {String} ERR Some other error
+ """
+ tag = self.datastore.data['settings']['application']['tags'].get(uuid)
+ if not tag:
+ abort(404, message='No tag exists with the UUID of {}'.format(uuid))
+
+ tag.update(request.json)
+ self.datastore.needs_write_urgent = True
+
+ return "OK", 200
+
+
+ @auth.check_token
+ # Only cares for {'title': 'xxxx'}
+ def post(self):
+ """
+ @api {post} /api/v1/watch Create a single tag
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"name": "Work related"}'
+ @apiName Create
+ @apiGroup Tag
+ @apiSuccess (200) {String} OK Was created
+ @apiSuccess (500) {String} ERR Some other error
+ """
+
+ json_data = request.get_json()
+ title = json_data.get("title",'').strip()
+
+
+ new_uuid = self.datastore.add_tag(title=title)
+ if new_uuid:
+ return {'uuid': new_uuid}, 201
+ else:
+ return "Invalid or unsupported tag", 400
+
+class Tags(Resource):
+ def __init__(self, **kwargs):
+ # datastore is a black box dependency
+ self.datastore = kwargs['datastore']
+
+ @auth.check_token
+ def get(self):
+ """
+ @api {get} /api/v1/tags List tags
+ @apiDescription Return list of available tags
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/tags -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ {
+ "cc0cfffa-f449-477b-83ea-0caafd1dc091": {
+ "title": "Tech News",
+ "notification_muted": false,
+ "date_created": 1677103794
+ },
+ "e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
+ "title": "Shopping",
+ "notification_muted": true,
+ "date_created": 1676662819
+ }
+ }
+ @apiName ListTags
+ @apiGroup Tag Management
+ @apiSuccess (200) {String} OK JSON dict
+ """
+ result = {}
+ for uuid, tag in self.datastore.data['settings']['application']['tags'].items():
+ result[uuid] = {
+ 'date_created': tag.get('date_created', 0),
+ 'notification_muted': tag.get('notification_muted', False),
+ 'title': tag.get('title', ''),
+ 'uuid': tag.get('uuid')
+ }
+
+ return result, 200
\ No newline at end of file
diff --git a/changedetectionio/api/Watch.py b/changedetectionio/api/Watch.py
new file mode 100644
index 0000000000000000000000000000000000000000..b17064bd12ac43133cd3f58e3cf161004d2bb2ab
--- /dev/null
+++ b/changedetectionio/api/Watch.py
@@ -0,0 +1,298 @@
+import os
+from changedetectionio.strtobool import strtobool
+
+from flask_expects_json import expects_json
+from changedetectionio import queuedWatchMetaData
+from changedetectionio import worker_handler
+from flask_restful import abort, Resource
+from flask import request, make_response
+import validators
+from . import auth
+import copy
+
+# Import schemas from __init__.py
+from . import schema, schema_create_watch, schema_update_watch
+
+
+class Watch(Resource):
+ def __init__(self, **kwargs):
+ # datastore is a black box dependency
+ self.datastore = kwargs['datastore']
+ self.update_q = kwargs['update_q']
+
+ # Get information about a single watch, excluding the history list (can be large)
+ # curl http://localhost:7860/api/v1/watch/
+ # @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not "OK"
+ # ?recheck=true
+ @auth.check_token
+ def get(self, uuid):
+ """
+ @api {get} /api/v1/watch/:uuid Single watch - get data, recheck, pause, mute.
+ @apiDescription Retrieve watch information and set muted/paused status
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ curl "http://localhost:7860/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=unmuted" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ curl "http://localhost:7860/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?paused=unpaused" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ @apiName Watch
+ @apiGroup Watch
+ @apiParam {uuid} uuid Watch unique ID.
+ @apiQuery {Boolean} [recheck] Recheck this watch `recheck=1`
+ @apiQuery {String} [paused] =`paused` or =`unpaused` , Sets the PAUSED state
+ @apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state
+ @apiSuccess (200) {String} OK When paused/muted/recheck operation OR full JSON object of the watch
+ @apiSuccess (200) {JSON} WatchJSON JSON Full JSON object of the watch
+ """
+ from copy import deepcopy
+ watch = deepcopy(self.datastore.data['watching'].get(uuid))
+ if not watch:
+ abort(404, message='No watch exists with the UUID of {}'.format(uuid))
+
+ if request.args.get('recheck'):
+ worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
+ return "OK", 200
+ if request.args.get('paused', '') == 'paused':
+ self.datastore.data['watching'].get(uuid).pause()
+ return "OK", 200
+ elif request.args.get('paused', '') == 'unpaused':
+ self.datastore.data['watching'].get(uuid).unpause()
+ return "OK", 200
+ if request.args.get('muted', '') == 'muted':
+ self.datastore.data['watching'].get(uuid).mute()
+ return "OK", 200
+ elif request.args.get('muted', '') == 'unmuted':
+ self.datastore.data['watching'].get(uuid).unmute()
+ return "OK", 200
+
+ # 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
+
+ @auth.check_token
+ def delete(self, uuid):
+ """
+ @api {delete} /api/v1/watch/:uuid Delete a watch and related history
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ @apiParam {uuid} uuid Watch unique ID.
+ @apiName Delete
+ @apiGroup Watch
+ @apiSuccess (200) {String} OK Was deleted
+ """
+ if not self.datastore.data['watching'].get(uuid):
+ abort(400, message='No watch exists with the UUID of {}'.format(uuid))
+
+ self.datastore.delete(uuid)
+ return 'OK', 204
+
+ @auth.check_token
+ @expects_json(schema_update_watch)
+ def put(self, uuid):
+ """
+ @api {put} /api/v1/watch/:uuid Update watch information
+ @apiExample {curl} Example usage:
+ Update (PUT)
+ curl http://localhost:7860/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "new list"}'
+
+ @apiDescription Updates an existing watch using JSON, accepts the same structure as returned in get single watch information
+ @apiParam {uuid} uuid Watch unique ID.
+ @apiName Update a watch
+ @apiGroup Watch
+ @apiSuccess (200) {String} OK Was updated
+ @apiSuccess (500) {String} ERR Some other error
+ """
+ watch = self.datastore.data['watching'].get(uuid)
+ if not watch:
+ abort(404, message='No watch exists with the UUID of {}'.format(uuid))
+
+ if request.json.get('proxy'):
+ plist = self.datastore.proxy_list
+ if not request.json.get('proxy') in plist:
+ return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400
+
+ watch.update(request.json)
+
+ return "OK", 200
+
+
+class WatchHistory(Resource):
+ def __init__(self, **kwargs):
+ # datastore is a black box dependency
+ self.datastore = kwargs['datastore']
+
+ # Get a list of available history for a watch by UUID
+ # curl http://localhost:7860/api/v1/watch//history
+ @auth.check_token
+ def get(self, uuid):
+ """
+ @api {get} /api/v1/watch//history Get a list of all historical snapshots available for a watch
+ @apiDescription Requires `uuid`, returns list
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
+ {
+ "1676649279": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/cb7e9be8258368262246910e6a2a4c30.txt",
+ "1677092785": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/e20db368d6fc633e34f559ff67bb4044.txt",
+ "1677103794": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/02efdd37dacdae96554a8cc85dc9c945.txt"
+ }
+ @apiName Get list of available stored snapshots for watch
+ @apiGroup Watch History
+ @apiSuccess (200) {String} OK
+ @apiSuccess (404) {String} ERR Not found
+ """
+ watch = self.datastore.data['watching'].get(uuid)
+ if not watch:
+ abort(404, message='No watch exists with the UUID of {}'.format(uuid))
+ return watch.history, 200
+
+
+class WatchSingleHistory(Resource):
+ def __init__(self, **kwargs):
+ # datastore is a black box dependency
+ self.datastore = kwargs['datastore']
+
+ @auth.check_token
+ def get(self, uuid, timestamp):
+ """
+ @api {get} /api/v1/watch//history/ Get single snapshot from watch
+ @apiDescription Requires watch `uuid` and `timestamp`. `timestamp` of "`latest`" for latest available snapshot, or use the list returned here
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history/1677092977 -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
+ @apiName Get single snapshot content
+ @apiGroup Watch History
+ @apiParam {String} [html] Optional Set to =1 to return the last HTML (only stores last 2 snapshots, use `latest` as timestamp)
+ @apiSuccess (200) {String} OK
+ @apiSuccess (404) {String} ERR Not found
+ """
+ watch = self.datastore.data['watching'].get(uuid)
+ if not watch:
+ abort(404, message=f"No watch exists with the UUID of {uuid}")
+
+ if not len(watch.history):
+ abort(404, message=f"Watch found but no history exists for the UUID {uuid}")
+
+ if timestamp == 'latest':
+ timestamp = list(watch.history.keys())[-1]
+
+ if request.args.get('html'):
+ content = watch.get_fetched_html(timestamp)
+ if content:
+ response = make_response(content, 200)
+ response.mimetype = "text/html"
+ else:
+ response = make_response("No content found", 404)
+ response.mimetype = "text/plain"
+ else:
+ content = watch.get_history_snapshot(timestamp)
+ response = make_response(content, 200)
+ response.mimetype = "text/plain"
+
+ return response
+
+
+class CreateWatch(Resource):
+ def __init__(self, **kwargs):
+ # datastore is a black box dependency
+ self.datastore = kwargs['datastore']
+ self.update_q = kwargs['update_q']
+
+ @auth.check_token
+ @expects_json(schema_create_watch)
+ def post(self):
+ """
+ @api {post} /api/v1/watch Create a single watch
+ @apiDescription Requires atleast `url` set, can accept the same structure as get single watch information to create.
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "nice list"}'
+ @apiName Create
+ @apiGroup Watch
+ @apiSuccess (200) {String} OK Was created
+ @apiSuccess (500) {String} ERR Some other error
+ """
+
+ json_data = request.get_json()
+ url = json_data['url'].strip()
+
+ # If hosts that only contain alphanumerics are allowed ("localhost" for example)
+ allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
+ if not validators.url(url, simple_host=allow_simplehost):
+ return "Invalid or unsupported URL", 400
+
+ if json_data.get('proxy'):
+ plist = self.datastore.proxy_list
+ if not json_data.get('proxy') in plist:
+ return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400
+
+ extras = copy.deepcopy(json_data)
+
+ # Because we renamed 'tag' to 'tags' but don't want to change the API (can do this in v2 of the API)
+ tags = None
+ if extras.get('tag'):
+ tags = extras.get('tag')
+ del extras['tag']
+
+ del extras['url']
+
+ new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
+ if new_uuid:
+ worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
+ return {'uuid': new_uuid}, 201
+ else:
+ return "Invalid or unsupported URL", 400
+
+ @auth.check_token
+ def get(self):
+ """
+ @api {get} /api/v1/watch List watches
+ @apiDescription Return concise list of available watches and some very basic info
+ @apiExample {curl} Example usage:
+ curl http://localhost:7860/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45"
+ {
+ "6a4b7d5c-fee4-4616-9f43-4ac97046b595": {
+ "last_changed": 1677103794,
+ "last_checked": 1677103794,
+ "last_error": false,
+ "title": "",
+ "url": "http://www.quotationspage.com/random.php"
+ },
+ "e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
+ "last_changed": 0,
+ "last_checked": 1676662819,
+ "last_error": false,
+ "title": "QuickLook",
+ "url": "https://github.com/QL-Win/QuickLook/tags"
+ }
+ }
+
+ @apiParam {String} [recheck_all] Optional Set to =1 to force recheck of all watches
+ @apiParam {String} [tag] Optional name of tag to limit results
+ @apiName ListWatches
+ @apiGroup Watch Management
+ @apiSuccess (200) {String} OK JSON dict
+ """
+ list = {}
+
+ tag_limit = request.args.get('tag', '').lower()
+ for uuid, watch in self.datastore.data['watching'].items():
+ # Watch tags by name (replace the other calls?)
+ tags = self.datastore.get_all_tags_for_watch(uuid=uuid)
+ if tag_limit and not any(v.get('title').lower() == tag_limit for k, v in tags.items()):
+ continue
+
+ list[uuid] = {
+ 'last_changed': watch.last_changed,
+ 'last_checked': watch['last_checked'],
+ 'last_error': watch['last_error'],
+ 'title': watch['title'],
+ 'url': watch['url'],
+ 'viewed': watch.viewed
+ }
+
+ if request.args.get('recheck_all'):
+ for uuid in self.datastore.data['watching'].keys():
+ worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
+ return {'status': "OK"}, 200
+
+ return list, 200
\ No newline at end of file
diff --git a/changedetectionio/api/__init__.py b/changedetectionio/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..470890cf7a9ed0fa707d16ff223e17917a4d65ff
--- /dev/null
+++ b/changedetectionio/api/__init__.py
@@ -0,0 +1,33 @@
+import copy
+from . import api_schema
+from ..model import watch_base
+
+# Build a JSON Schema atleast partially based on our Watch model
+watch_base_config = watch_base()
+schema = api_schema.build_watch_json_schema(watch_base_config)
+
+schema_create_watch = copy.deepcopy(schema)
+schema_create_watch['required'] = ['url']
+
+schema_update_watch = copy.deepcopy(schema)
+schema_update_watch['additionalProperties'] = False
+
+# Tag schema is also based on watch_base since Tag inherits from it
+schema_tag = copy.deepcopy(schema)
+schema_create_tag = copy.deepcopy(schema_tag)
+schema_create_tag['required'] = ['title']
+schema_update_tag = copy.deepcopy(schema_tag)
+schema_update_tag['additionalProperties'] = False
+
+schema_notification_urls = copy.deepcopy(schema)
+schema_create_notification_urls = copy.deepcopy(schema_notification_urls)
+schema_create_notification_urls['required'] = ['notification_urls']
+schema_delete_notification_urls = copy.deepcopy(schema_notification_urls)
+schema_delete_notification_urls['required'] = ['notification_urls']
+
+# Import all API resources
+from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch
+from .Tags import Tags, Tag
+from .Import import Import
+from .SystemInfo import SystemInfo
+from .Notifications import Notifications
diff --git a/changedetectionio/api/api_schema.py b/changedetectionio/api/api_schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..c181d6a068929a9aa34450ff9335806a7a3c12b3
--- /dev/null
+++ b/changedetectionio/api/api_schema.py
@@ -0,0 +1,146 @@
+# Responsible for building the storage dict into a set of rules ("JSON Schema") acceptable via the API
+# Probably other ways to solve this when the backend switches to some ORM
+from changedetectionio.notification import valid_notification_formats
+
+
+def build_time_between_check_json_schema():
+ # Setup time between check schema
+ schema_properties_time_between_check = {
+ "type": "object",
+ "additionalProperties": False,
+ "properties": {}
+ }
+ for p in ['weeks', 'days', 'hours', 'minutes', 'seconds']:
+ schema_properties_time_between_check['properties'][p] = {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+
+ return schema_properties_time_between_check
+
+def build_watch_json_schema(d):
+ # Base JSON schema
+ schema = {
+ 'type': 'object',
+ 'properties': {},
+ }
+
+ for k, v in d.items():
+ # @todo 'integer' is not covered here because its almost always for internal usage
+
+ if isinstance(v, type(None)):
+ schema['properties'][k] = {
+ "anyOf": [
+ {"type": "null"},
+ ]
+ }
+ elif isinstance(v, list):
+ schema['properties'][k] = {
+ "anyOf": [
+ {"type": "array",
+ # Always is an array of strings, like text or regex or something
+ "items": {
+ "type": "string",
+ "maxLength": 5000
+ }
+ },
+ ]
+ }
+ elif isinstance(v, bool):
+ schema['properties'][k] = {
+ "anyOf": [
+ {"type": "boolean"},
+ ]
+ }
+ elif isinstance(v, str):
+ schema['properties'][k] = {
+ "anyOf": [
+ {"type": "string",
+ "maxLength": 5000},
+ ]
+ }
+
+ # Can also be a string (or None by default above)
+ for v in ['body',
+ 'notification_body',
+ 'notification_format',
+ 'notification_title',
+ 'proxy',
+ 'tag',
+ 'title',
+ 'webdriver_js_execute_code'
+ ]:
+ schema['properties'][v]['anyOf'].append({'type': 'string', "maxLength": 5000})
+
+ # None or Boolean
+ schema['properties']['track_ldjson_price_data']['anyOf'].append({'type': 'boolean'})
+
+ schema['properties']['method'] = {"type": "string",
+ "enum": ["GET", "POST", "DELETE", "PUT"]
+ }
+
+ schema['properties']['fetch_backend']['anyOf'].append({"type": "string",
+ "enum": ["html_requests", "html_webdriver"]
+ })
+
+
+
+ # All headers must be key/value type dict
+ schema['properties']['headers'] = {
+ "type": "object",
+ "patternProperties": {
+ # Should always be a string:string type value
+ ".*": {"type": "string"},
+ }
+ }
+
+ schema['properties']['notification_format'] = {'type': 'string',
+ 'enum': list(valid_notification_formats.keys())
+ }
+
+ # Stuff that shouldn't be available but is just state-storage
+ for v in ['previous_md5', 'last_error', 'has_ldjson_price_data', 'previous_md5_before_filters', 'uuid']:
+ del schema['properties'][v]
+
+ schema['properties']['webdriver_delay']['anyOf'].append({'type': 'integer'})
+
+ 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
+
diff --git a/changedetectionio/api/auth.py b/changedetectionio/api/auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..64e8dbb10de63edaefe5f4d99654648ecaa86489
--- /dev/null
+++ b/changedetectionio/api/auth.py
@@ -0,0 +1,25 @@
+from flask import request, make_response, jsonify
+from functools import wraps
+
+
+# Simple API auth key comparison
+# @todo - Maybe short lived token in the future?
+
+def check_token(f):
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ datastore = args[0].datastore
+
+ config_api_token_enabled = datastore.data['settings']['application'].get('api_access_token_enabled')
+ config_api_token = datastore.data['settings']['application'].get('api_access_token')
+
+ # config_api_token_enabled - a UI option in settings if access should obey the key or not
+ if config_api_token_enabled:
+ if request.headers.get('x-api-key') != config_api_token:
+ return make_response(
+ jsonify("Invalid access - API key invalid."), 403
+ )
+
+ return f(*args, **kwargs)
+
+ return decorated
diff --git a/changedetectionio/async_update_worker.py b/changedetectionio/async_update_worker.py
new file mode 100644
index 0000000000000000000000000000000000000000..ae0501b0926d16487a74ea183439bf5e34cc6ff9
--- /dev/null
+++ b/changedetectionio/async_update_worker.py
@@ -0,0 +1,449 @@
+from .processors.exceptions import ProcessorException
+import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions
+from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
+from changedetectionio import html_tools
+from changedetectionio.flask_app import watch_check_update
+
+import asyncio
+import importlib
+import os
+import time
+
+from loguru import logger
+
+# Async version of update_worker
+# Processes jobs from AsyncSignalPriorityQueue instead of threaded queue
+
+async def async_update_worker(worker_id, q, notification_q, app, datastore):
+ """
+ Async worker function that processes watch check jobs from the queue.
+
+ Args:
+ worker_id: Unique identifier for this worker
+ q: AsyncSignalPriorityQueue containing jobs to process
+ notification_q: Standard queue for notifications
+ app: Flask application instance
+ datastore: Application datastore
+ """
+ # Set a descriptive name for this task
+ task = asyncio.current_task()
+ if task:
+ task.set_name(f"async-worker-{worker_id}")
+
+ logger.info(f"Starting async worker {worker_id}")
+
+ while not app.config.exit.is_set():
+ update_handler = None
+ watch = None
+
+ try:
+ # Use asyncio wait_for to make queue.get() cancellable
+ queued_item_data = await asyncio.wait_for(q.get(), timeout=1.0)
+ except asyncio.TimeoutError:
+ # No jobs available, continue loop
+ continue
+ except Exception as e:
+ logger.error(f"Worker {worker_id} error getting queue item: {e}")
+ await asyncio.sleep(0.1)
+ continue
+
+ uuid = queued_item_data.item.get('uuid')
+ fetch_start_time = round(time.time())
+
+ # Mark this UUID as being processed
+ from changedetectionio import worker_handler
+ worker_handler.set_uuid_processing(uuid, processing=True)
+
+ try:
+ if uuid in list(datastore.data['watching'].keys()) and datastore.data['watching'][uuid].get('url'):
+ changed_detected = False
+ contents = b''
+ process_changedetection_results = True
+ update_obj = {}
+
+ # Clear last errors
+ datastore.data['watching'][uuid]['browser_steps_last_error_step'] = None
+ datastore.data['watching'][uuid]['last_checked'] = fetch_start_time
+
+ watch = datastore.data['watching'].get(uuid)
+
+ logger.info(f"Worker {worker_id} processing watch UUID {uuid} Priority {queued_item_data.priority} URL {watch['url']}")
+
+ try:
+ watch_check_update.send(watch_uuid=uuid)
+
+ # Processor is what we are using for detecting the "Change"
+ processor = watch.get('processor', 'text_json_diff')
+
+ # Init a new 'difference_detection_processor'
+ processor_module_name = f"changedetectionio.processors.{processor}.processor"
+ try:
+ processor_module = importlib.import_module(processor_module_name)
+ except ModuleNotFoundError as e:
+ print(f"Processor module '{processor}' not found.")
+ raise e
+
+ update_handler = processor_module.perform_site_check(datastore=datastore,
+ watch_uuid=uuid)
+
+ # All fetchers are now async, so call directly
+ await update_handler.call_browser()
+
+ # Run change detection (this is synchronous)
+ changed_detected, update_obj, contents = update_handler.run_changedetection(watch=watch)
+
+ except PermissionError as e:
+ logger.critical(f"File permission error updating file, watch: {uuid}")
+ logger.critical(str(e))
+ process_changedetection_results = False
+
+ except ProcessorException as e:
+ if e.screenshot:
+ watch.save_screenshot(screenshot=e.screenshot)
+ if e.xpath_data:
+ watch.save_xpath_data(data=e.xpath_data)
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': e.message})
+ process_changedetection_results = False
+
+ except content_fetchers_exceptions.ReplyWithContentButNoText as e:
+ extra_help = ""
+ if e.has_filters:
+ has_img = html_tools.include_filters(include_filters='img',
+ html_content=e.html_content)
+ if has_img:
+ extra_help = ", it's possible that the filters you have give an empty result or contain only an image."
+ else:
+ extra_help = ", it's possible that the filters were found, but contained no usable text."
+
+ datastore.update_watch(uuid=uuid, update_obj={
+ 'last_error': f"Got HTML content but no text found (With {e.status_code} reply code){extra_help}"
+ })
+
+ if e.screenshot:
+ watch.save_screenshot(screenshot=e.screenshot, as_error=True)
+
+ if e.xpath_data:
+ watch.save_xpath_data(data=e.xpath_data)
+
+ process_changedetection_results = False
+
+ except content_fetchers_exceptions.Non200ErrorCodeReceived as e:
+ if e.status_code == 403:
+ err_text = "Error - 403 (Access denied) received"
+ elif e.status_code == 404:
+ err_text = "Error - 404 (Page not found) received"
+ elif e.status_code == 407:
+ err_text = "Error - 407 (Proxy authentication required) received, did you need a username and password for the proxy?"
+ elif e.status_code == 500:
+ err_text = "Error - 500 (Internal server error) received from the web site"
+ else:
+ extra = ' (Access denied or blocked)' if str(e.status_code).startswith('4') else ''
+ err_text = f"Error - Request returned a HTTP error code {e.status_code}{extra}"
+
+ if e.screenshot:
+ watch.save_screenshot(screenshot=e.screenshot, as_error=True)
+ if e.xpath_data:
+ watch.save_xpath_data(data=e.xpath_data, as_error=True)
+ if e.page_text:
+ watch.save_error_text(contents=e.page_text)
+
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})
+ process_changedetection_results = False
+
+ except FilterNotFoundInResponse as e:
+ if not datastore.data['watching'].get(uuid):
+ continue
+
+ err_text = "Warning, no filters were found, no change detection ran - Did the page change layout? update your Visual Filter if necessary."
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})
+
+ # Filter wasnt found, but we should still update the visual selector so that they can have a chance to set it up again
+ if e.screenshot:
+ watch.save_screenshot(screenshot=e.screenshot)
+
+ if e.xpath_data:
+ watch.save_xpath_data(data=e.xpath_data)
+
+ # Only when enabled, send the notification
+ if watch.get('filter_failure_notification_send', False):
+ c = watch.get('consecutive_filter_failures', 0)
+ c += 1
+ # Send notification if we reached the threshold?
+ threshold = datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0)
+ logger.debug(f"Filter for {uuid} not found, consecutive_filter_failures: {c} of threshold {threshold}")
+ if c >= threshold:
+ if not watch.get('notification_muted'):
+ logger.debug(f"Sending filter failed notification for {uuid}")
+ await send_filter_failure_notification(uuid, notification_q, datastore)
+ c = 0
+ logger.debug(f"Reset filter failure count back to zero")
+
+ datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c})
+ else:
+ logger.trace(f"{uuid} - filter_failure_notification_send not enabled, skipping")
+
+ process_changedetection_results = False
+
+ except content_fetchers_exceptions.checksumFromPreviousCheckWasTheSame as e:
+ # Yes fine, so nothing todo, don't continue to process.
+ process_changedetection_results = False
+ changed_detected = False
+
+ except content_fetchers_exceptions.BrowserConnectError as e:
+ datastore.update_watch(uuid=uuid,
+ update_obj={'last_error': e.msg})
+ process_changedetection_results = False
+
+ except content_fetchers_exceptions.BrowserFetchTimedOut as e:
+ datastore.update_watch(uuid=uuid,
+ update_obj={'last_error': e.msg})
+ process_changedetection_results = False
+
+ except content_fetchers_exceptions.BrowserStepsStepException as e:
+ if not datastore.data['watching'].get(uuid):
+ continue
+
+ error_step = e.step_n + 1
+ from playwright._impl._errors import TimeoutError, Error
+
+ # Generally enough info for TimeoutError (couldnt locate the element after default seconds)
+ err_text = f"Browser step at position {error_step} could not run, check the watch, add a delay if necessary, view Browser Steps to see screenshot at that step."
+
+ if e.original_e.name == "TimeoutError":
+ # Just the first line is enough, the rest is the stack trace
+ err_text += " Could not find the target."
+ else:
+ # Other Error, more info is good.
+ err_text += " " + str(e.original_e).splitlines()[0]
+
+ logger.debug(f"BrowserSteps exception at step {error_step} {str(e.original_e)}")
+
+ datastore.update_watch(uuid=uuid,
+ update_obj={'last_error': err_text,
+ 'browser_steps_last_error_step': error_step})
+
+ if watch.get('filter_failure_notification_send', False):
+ c = watch.get('consecutive_filter_failures', 0)
+ c += 1
+ # Send notification if we reached the threshold?
+ threshold = datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0)
+ logger.error(f"Step for {uuid} not found, consecutive_filter_failures: {c}")
+ if threshold > 0 and c >= threshold:
+ if not watch.get('notification_muted'):
+ await send_step_failure_notification(watch_uuid=uuid, step_n=e.step_n, notification_q=notification_q, datastore=datastore)
+ c = 0
+
+ datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c})
+
+ process_changedetection_results = False
+
+ except content_fetchers_exceptions.EmptyReply as e:
+ # Some kind of custom to-str handler in the exception handler that does this?
+ err_text = "EmptyReply - try increasing 'Wait seconds before extracting text', Status Code {}".format(e.status_code)
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
+ 'last_check_status': e.status_code})
+ process_changedetection_results = False
+
+ except content_fetchers_exceptions.ScreenshotUnavailable as e:
+ err_text = "Screenshot unavailable, page did not render fully in the expected time or page was too long - try increasing 'Wait seconds before extracting text'"
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
+ 'last_check_status': e.status_code})
+ process_changedetection_results = False
+
+ except content_fetchers_exceptions.JSActionExceptions as e:
+ err_text = "Error running JS Actions - Page request - "+e.message
+ if e.screenshot:
+ watch.save_screenshot(screenshot=e.screenshot, as_error=True)
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
+ 'last_check_status': e.status_code})
+ process_changedetection_results = False
+
+ except content_fetchers_exceptions.PageUnloadable as e:
+ err_text = "Page request from server didnt respond correctly"
+ if e.message:
+ err_text = "{} - {}".format(err_text, e.message)
+
+ if e.screenshot:
+ watch.save_screenshot(screenshot=e.screenshot, as_error=True)
+
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,
+ 'last_check_status': e.status_code,
+ 'has_ldjson_price_data': None})
+ process_changedetection_results = False
+
+ except content_fetchers_exceptions.BrowserStepsInUnsupportedFetcher as e:
+ err_text = "This watch has Browser Steps configured and so it cannot run with the 'Basic fast Plaintext/HTTP Client', either remove the Browser Steps or select a Chrome fetcher."
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})
+ process_changedetection_results = False
+ logger.error(f"Exception (BrowserStepsInUnsupportedFetcher) reached processing watch UUID: {uuid}")
+
+ except Exception as e:
+ logger.error(f"Worker {worker_id} exception processing watch UUID: {uuid}")
+ logger.error(str(e))
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': "Exception: " + str(e)})
+ process_changedetection_results = False
+
+ else:
+ if not datastore.data['watching'].get(uuid):
+ continue
+
+ update_obj['content-type'] = update_handler.fetcher.get_all_headers().get('content-type', '').lower()
+
+ if not watch.get('ignore_status_codes'):
+ update_obj['consecutive_filter_failures'] = 0
+
+ update_obj['last_error'] = False
+ cleanup_error_artifacts(uuid, datastore)
+
+ if not datastore.data['watching'].get(uuid):
+ continue
+
+ if process_changedetection_results:
+ # Extract title if needed
+ if datastore.data['settings']['application'].get('extract_title_as_title') or watch['extract_title_as_title']:
+ if not watch['title'] or not len(watch['title']):
+ try:
+ update_obj['title'] = html_tools.extract_element(find='title', html_content=update_handler.fetcher.content)
+ logger.info(f"UUID: {uuid} Extract updated title to '{update_obj['title']}")
+ except Exception as e:
+ logger.warning(f"UUID: {uuid} Extract as watch title was enabled, but couldn't find a .")
+
+ try:
+ datastore.update_watch(uuid=uuid, update_obj=update_obj)
+
+ if changed_detected or not watch.history_n:
+ if update_handler.screenshot:
+ watch.save_screenshot(screenshot=update_handler.screenshot)
+
+ if update_handler.xpath_data:
+ watch.save_xpath_data(data=update_handler.xpath_data)
+
+ # Ensure unique timestamp for history
+ if watch.newest_history_key and int(fetch_start_time) == int(watch.newest_history_key):
+ logger.warning(f"Timestamp {fetch_start_time} already exists, waiting 1 seconds")
+ fetch_start_time += 1
+ await asyncio.sleep(1)
+
+ watch.save_history_text(contents=contents,
+ timestamp=int(fetch_start_time),
+ snapshot_id=update_obj.get('previous_md5', 'none'))
+
+ empty_pages_are_a_change = 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):
+ watch.save_last_fetched_html(contents=update_handler.fetcher.content, timestamp=int(fetch_start_time))
+
+ # Send notifications on second+ check
+ if watch.history_n >= 2:
+ logger.info(f"Change detected in UUID {uuid} - {watch['url']}")
+ if not watch.get('notification_muted'):
+ await send_content_changed_notification(uuid, notification_q, datastore)
+
+ except Exception as e:
+ logger.critical(f"Worker {worker_id} exception in process_changedetection_results")
+ logger.critical(str(e))
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)})
+
+ # Always record attempt count
+ count = watch.get('check_count', 0) + 1
+
+ # Record server header
+ try:
+ server_header = update_handler.fetcher.headers.get('server', '').strip().lower()[:255]
+ datastore.update_watch(uuid=uuid, update_obj={'remote_server_reply': server_header})
+ except Exception as e:
+ pass
+
+ datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3),
+ 'check_count': count})
+
+ except Exception as e:
+ logger.error(f"Worker {worker_id} unexpected error processing {uuid}: {e}")
+ logger.error(f"Worker {worker_id} traceback:", exc_info=True)
+
+ # Also update the watch with error information
+ if datastore and uuid in datastore.data['watching']:
+ datastore.update_watch(uuid=uuid, update_obj={'last_error': f"Worker error: {str(e)}"})
+
+ finally:
+ # Always cleanup - this runs whether there was an exception or not
+ if uuid:
+ try:
+ # Mark UUID as no longer being processed
+ worker_handler.set_uuid_processing(uuid, processing=False)
+
+ # Send completion signal
+ if watch:
+ #logger.info(f"Worker {worker_id} sending completion signal for UUID {watch['uuid']}")
+ watch_check_update.send(watch_uuid=watch['uuid'])
+
+ update_handler = None
+ logger.debug(f"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s")
+ except Exception as cleanup_error:
+ logger.error(f"Worker {worker_id} error during cleanup: {cleanup_error}")
+
+ # Brief pause before continuing to avoid tight error loops (only on error)
+ if 'e' in locals():
+ await asyncio.sleep(1.0)
+ else:
+ # Small yield for normal completion
+ await asyncio.sleep(0.01)
+
+ # Check if we should exit
+ if app.config.exit.is_set():
+ break
+
+ # Check if we're in pytest environment - if so, be more gentle with logging
+ import sys
+ in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
+
+ if not in_pytest:
+ logger.info(f"Worker {worker_id} shutting down")
+
+
+def cleanup_error_artifacts(uuid, datastore):
+ """Helper function to clean up error artifacts"""
+ cleanup_files = ["last-error-screenshot.png", "last-error.txt"]
+ for f in cleanup_files:
+ full_path = os.path.join(datastore.datastore_path, uuid, f)
+ if os.path.isfile(full_path):
+ os.unlink(full_path)
+
+
+
+async def send_content_changed_notification(watch_uuid, notification_q, datastore):
+ """Helper function to queue notifications using the new notification service"""
+ try:
+ from changedetectionio.notification_service import create_notification_service
+
+ # Create notification service instance
+ notification_service = create_notification_service(datastore, notification_q)
+
+ notification_service.send_content_changed_notification(watch_uuid)
+ except Exception as e:
+ logger.error(f"Error sending notification for {watch_uuid}: {e}")
+
+
+async def send_filter_failure_notification(watch_uuid, notification_q, datastore):
+ """Helper function to send filter failure notifications using the new notification service"""
+ try:
+ from changedetectionio.notification_service import create_notification_service
+
+ # Create notification service instance
+ notification_service = create_notification_service(datastore, notification_q)
+
+ notification_service.send_filter_failure_notification(watch_uuid)
+ except Exception as e:
+ logger.error(f"Error sending filter failure notification for {watch_uuid}: {e}")
+
+
+async def send_step_failure_notification(watch_uuid, step_n, notification_q, datastore):
+ """Helper function to send step failure notifications using the new notification service"""
+ try:
+ from changedetectionio.notification_service import create_notification_service
+
+ # Create notification service instance
+ notification_service = create_notification_service(datastore, notification_q)
+
+ notification_service.send_step_failure_notification(watch_uuid, step_n)
+ except Exception as e:
+ logger.error(f"Error sending step failure notification for {watch_uuid}: {e}")
\ No newline at end of file
diff --git a/changedetectionio/auth_decorator.py b/changedetectionio/auth_decorator.py
new file mode 100644
index 0000000000000000000000000000000000000000..3f495762d1728dd0caeb340e226cfeaadb936b86
--- /dev/null
+++ b/changedetectionio/auth_decorator.py
@@ -0,0 +1,33 @@
+import os
+from functools import wraps
+from flask import current_app, redirect, request
+from loguru import logger
+
+def login_optionally_required(func):
+ """
+ If password authentication is enabled, verify the user is logged in.
+ To be used as a decorator for routes that should optionally require login.
+ This version is blueprint-friendly as it uses current_app instead of directly accessing app.
+ """
+ @wraps(func)
+ def decorated_view(*args, **kwargs):
+ from flask import current_app
+ import flask_login
+ from flask_login import current_user
+
+ # Access datastore through the app config
+ datastore = current_app.config['DATASTORE']
+ has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False)
+
+ # Permitted
+ if request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):
+ return func(*args, **kwargs)
+ elif request.method in flask_login.config.EXEMPT_METHODS:
+ return func(*args, **kwargs)
+ elif current_app.config.get('LOGIN_DISABLED'):
+ return func(*args, **kwargs)
+ elif has_password_enabled and not current_user.is_authenticated:
+ return current_app.login_manager.unauthorized()
+
+ return func(*args, **kwargs)
+ return decorated_view
\ No newline at end of file
diff --git a/changedetectionio/blueprint/__init__.py b/changedetectionio/blueprint/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/changedetectionio/blueprint/backups/__init__.py b/changedetectionio/blueprint/backups/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe793d1c4feb5ea40751c1e7e6b744a06aed789e
--- /dev/null
+++ b/changedetectionio/blueprint/backups/__init__.py
@@ -0,0 +1,164 @@
+import datetime
+import glob
+import threading
+
+from flask import Blueprint, render_template, send_from_directory, flash, url_for, redirect, abort
+import os
+
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.flask_app import login_optionally_required
+from loguru import logger
+
+BACKUP_FILENAME_FORMAT = "changedetection-backup-{}.zip"
+
+
+def create_backup(datastore_path, watches: dict):
+ logger.debug("Creating backup...")
+ import zipfile
+ from pathlib import Path
+
+ # create a ZipFile object
+ timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
+ backupname = BACKUP_FILENAME_FORMAT.format(timestamp)
+ backup_filepath = os.path.join(datastore_path, backupname)
+
+ with zipfile.ZipFile(backup_filepath.replace('.zip', '.tmp'), "w",
+ compression=zipfile.ZIP_DEFLATED,
+ compresslevel=8) as zipObj:
+
+ # Add the index
+ zipObj.write(os.path.join(datastore_path, "url-watches.json"), arcname="url-watches.json")
+
+ # Add the flask app secret
+ zipObj.write(os.path.join(datastore_path, "secret.txt"), arcname="secret.txt")
+
+ # Add any data in the watch data directory.
+ for uuid, w in watches.items():
+ for f in Path(w.watch_data_dir).glob('*'):
+ zipObj.write(f,
+ # Use the full path to access the file, but make the file 'relative' in the Zip.
+ arcname=os.path.join(f.parts[-2], f.parts[-1]),
+ compress_type=zipfile.ZIP_DEFLATED,
+ compresslevel=8)
+
+ # Create a list file with just the URLs, so it's easier to port somewhere else in the future
+ list_file = "url-list.txt"
+ with open(os.path.join(datastore_path, list_file), "w") as f:
+ for uuid in watches:
+ url = watches[uuid]["url"]
+ f.write("{}\r\n".format(url))
+ list_with_tags_file = "url-list-with-tags.txt"
+ with open(
+ os.path.join(datastore_path, list_with_tags_file), "w"
+ ) as f:
+ for uuid in watches:
+ url = watches[uuid].get('url')
+ tag = watches[uuid].get('tags', {})
+ f.write("{} {}\r\n".format(url, tag))
+
+ # Add it to the Zip
+ zipObj.write(
+ os.path.join(datastore_path, list_file),
+ arcname=list_file,
+ compress_type=zipfile.ZIP_DEFLATED,
+ compresslevel=8,
+ )
+ zipObj.write(
+ os.path.join(datastore_path, list_with_tags_file),
+ arcname=list_with_tags_file,
+ compress_type=zipfile.ZIP_DEFLATED,
+ compresslevel=8,
+ )
+
+ # Now it's done, rename it so it shows up finally and its completed being written.
+ os.rename(backup_filepath.replace('.zip', '.tmp'), backup_filepath.replace('.tmp', '.zip'))
+
+
+def construct_blueprint(datastore: ChangeDetectionStore):
+ backups_blueprint = Blueprint('backups', __name__, template_folder="templates")
+ backup_threads = []
+
+ @login_optionally_required
+ @backups_blueprint.route("/request-backup", methods=['GET'])
+ def request_backup():
+ if any(thread.is_alive() for thread in backup_threads):
+ flash("A backup is already running, check back in a few minutes", "error")
+ return redirect(url_for('backups.index'))
+
+ if len(find_backups()) > int(os.getenv("MAX_NUMBER_BACKUPS", 100)):
+ flash("Maximum number of backups reached, please remove some", "error")
+ return redirect(url_for('backups.index'))
+
+ # Be sure we're written fresh
+ datastore.sync_to_json()
+ zip_thread = threading.Thread(target=create_backup, args=(datastore.datastore_path, datastore.data.get("watching")))
+ zip_thread.start()
+ backup_threads.append(zip_thread)
+ flash("Backup building in background, check back in a few minutes.")
+
+ return redirect(url_for('backups.index'))
+
+ def find_backups():
+ backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))
+ backups = glob.glob(backup_filepath)
+ backup_info = []
+
+ for backup in backups:
+ size = os.path.getsize(backup) / (1024 * 1024)
+ creation_time = os.path.getctime(backup)
+ backup_info.append({
+ 'filename': os.path.basename(backup),
+ 'filesize': f"{size:.2f}",
+ 'creation_time': creation_time
+ })
+
+ backup_info.sort(key=lambda x: x['creation_time'], reverse=True)
+
+ return backup_info
+
+ @login_optionally_required
+ @backups_blueprint.route("/download/", methods=['GET'])
+ def download_backup(filename):
+ import re
+ filename = filename.strip()
+ backup_filename_regex = BACKUP_FILENAME_FORMAT.format("\d+")
+
+ full_path = os.path.join(os.path.abspath(datastore.datastore_path), filename)
+ if not full_path.startswith(os.path.abspath(datastore.datastore_path)):
+ abort(404)
+
+ if filename == 'latest':
+ backups = find_backups()
+ filename = backups[0]['filename']
+
+ if not re.match(r"^" + backup_filename_regex + "$", filename):
+ abort(400) # Bad Request if the filename doesn't match the pattern
+
+ logger.debug(f"Backup download request for '{full_path}'")
+ return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
+
+ @login_optionally_required
+ @backups_blueprint.route("", methods=['GET'])
+ def index():
+ backups = find_backups()
+ output = render_template("overview.html",
+ available_backups=backups,
+ backup_running=any(thread.is_alive() for thread in backup_threads)
+ )
+
+ return output
+
+ @login_optionally_required
+ @backups_blueprint.route("/remove-backups", methods=['GET'])
+ def remove_backups():
+
+ backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))
+ backups = glob.glob(backup_filepath)
+ for backup in backups:
+ os.unlink(backup)
+
+ flash("Backups were deleted.")
+
+ return redirect(url_for('backups.index'))
+
+ return backups_blueprint
diff --git a/changedetectionio/blueprint/backups/templates/overview.html b/changedetectionio/blueprint/backups/templates/overview.html
new file mode 100644
index 0000000000000000000000000000000000000000..b07be4bd08fd2f00530a286d9d68908317fe5b7d
--- /dev/null
+++ b/changedetectionio/blueprint/backups/templates/overview.html
@@ -0,0 +1,36 @@
+{% extends 'base.html' %}
+{% block content %}
+ {% from '_helpers.html' import render_simple_field, render_field %}
+
+
+
+{% endblock %}
diff --git a/changedetectionio/blueprint/browser_steps/TODO.txt b/changedetectionio/blueprint/browser_steps/TODO.txt
new file mode 100644
index 0000000000000000000000000000000000000000..7e586cfe5c11fd7d8cacb824cb402f2fd0f94cea
--- /dev/null
+++ b/changedetectionio/blueprint/browser_steps/TODO.txt
@@ -0,0 +1,7 @@
+- This needs an abstraction to directly handle the puppeteer connection methods
+- Then remove the playwright stuff
+- Remove hack redirect at line 65 changedetectionio/processors/__init__.py
+
+The screenshots are base64 encoded/decoded which is very CPU intensive for large screenshots (in playwright) but not
+in the direct puppeteer connection (they are binary end to end)
+
diff --git a/changedetectionio/blueprint/browser_steps/__init__.py b/changedetectionio/blueprint/browser_steps/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0c4b8743ea57b6d708de9f31f204f15f4cd359ac
--- /dev/null
+++ b/changedetectionio/blueprint/browser_steps/__init__.py
@@ -0,0 +1,254 @@
+
+# HORRIBLE HACK BUT WORKS :-) PR anyone?
+#
+# Why?
+# `browsersteps_playwright_browser_interface.chromium.connect_over_cdp()` will only run once without async()
+# - this flask app is not async()
+# - A single timeout/keepalive which applies to the session made at .connect_over_cdp()
+#
+# So it means that we must unfortunately for now just keep a single timer since .connect_over_cdp() was run
+# and know when that reaches timeout/keepalive :( when that time is up, restart the connection and tell the user
+# that their time is up, insert another coin. (reload)
+#
+#
+
+from changedetectionio.strtobool import strtobool
+from flask import Blueprint, request, make_response
+import os
+
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.flask_app import login_optionally_required
+from loguru import logger
+
+browsersteps_sessions = {}
+io_interface_context = None
+import json
+import hashlib
+from flask import Response
+import asyncio
+import threading
+
+def run_async_in_browser_loop(coro):
+ """Run async coroutine using the existing async worker event loop"""
+ from changedetectionio import worker_handler
+
+ # Use the existing async worker event loop instead of creating a new one
+ if worker_handler.USE_ASYNC_WORKERS and worker_handler.async_loop and not worker_handler.async_loop.is_closed():
+ logger.debug("Browser steps using existing async worker event loop")
+ future = asyncio.run_coroutine_threadsafe(coro, worker_handler.async_loop)
+ return future.result()
+ else:
+ # Fallback: create a new event loop (for sync workers or if async loop not available)
+ logger.debug("Browser steps creating temporary event loop")
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+def construct_blueprint(datastore: ChangeDetectionStore):
+ browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates")
+
+ async def start_browsersteps_session(watch_uuid):
+ from . import browser_steps
+ import time
+ from playwright.async_api import async_playwright
+
+ # We keep the playwright session open for many minutes
+ keepalive_seconds = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60
+
+ browsersteps_start_session = {'start_time': time.time()}
+
+ # Create a new async playwright instance for browser steps
+ playwright_instance = async_playwright()
+ playwright_context = await playwright_instance.start()
+
+ keepalive_ms = ((keepalive_seconds + 3) * 1000)
+ base_url = os.getenv('PLAYWRIGHT_DRIVER_URL', '').strip('"')
+ a = "?" if not '?' in base_url else '&'
+ base_url += a + f"timeout={keepalive_ms}"
+
+ browser = await playwright_context.chromium.connect_over_cdp(base_url, timeout=keepalive_ms)
+ browsersteps_start_session['browser'] = browser
+ browsersteps_start_session['playwright_context'] = playwright_context
+
+ proxy_id = datastore.get_preferred_proxy_for_watch(uuid=watch_uuid)
+ proxy = None
+ if proxy_id:
+ proxy_url = datastore.proxy_list.get(proxy_id).get('url')
+ if proxy_url:
+
+ # Playwright needs separate username and password values
+ from urllib.parse import urlparse
+ parsed = urlparse(proxy_url)
+ proxy = {'server': proxy_url}
+
+ if parsed.username:
+ proxy['username'] = parsed.username
+
+ if parsed.password:
+ proxy['password'] = parsed.password
+
+ logger.debug(f"Browser Steps: UUID {watch_uuid} selected proxy {proxy_url}")
+
+ # Tell Playwright to connect to Chrome and setup a new session via our stepper interface
+ browserstepper = browser_steps.browsersteps_live_ui(
+ playwright_browser=browser,
+ proxy=proxy,
+ start_url=datastore.data['watching'][watch_uuid].link,
+ headers=datastore.data['watching'][watch_uuid].get('headers')
+ )
+
+ # Initialize the async connection
+ await browserstepper.connect(proxy=proxy)
+
+ browsersteps_start_session['browserstepper'] = browserstepper
+
+ # For test
+ #await browsersteps_start_session['browserstepper'].action_goto_url(value="http://example.com?time="+str(time.time()))
+
+ return browsersteps_start_session
+
+
+ @login_optionally_required
+ @browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
+ def browsersteps_start_session():
+ # A new session was requested, return sessionID
+ import asyncio
+ import uuid
+ browsersteps_session_id = str(uuid.uuid4())
+ watch_uuid = request.args.get('uuid')
+
+ if not watch_uuid:
+ return make_response('No Watch UUID specified', 500)
+
+ logger.debug("Starting connection with playwright")
+ logger.debug("browser_steps.py connecting")
+
+ try:
+ # Run the async function in the dedicated browser steps event loop
+ browsersteps_sessions[browsersteps_session_id] = run_async_in_browser_loop(
+ start_browsersteps_session(watch_uuid)
+ )
+ except Exception as e:
+ if 'ECONNREFUSED' in str(e):
+ return make_response('Unable to start the Playwright Browser session, is sockpuppetbrowser running? Network configuration is OK?', 401)
+ else:
+ # Other errors, bad URL syntax, bad reply etc
+ return make_response(str(e), 401)
+
+ logger.debug("Starting connection with playwright - done")
+ return {'browsersteps_session_id': browsersteps_session_id}
+
+ @login_optionally_required
+ @browser_steps_blueprint.route("/browsersteps_image", methods=['GET'])
+ def browser_steps_fetch_screenshot_image():
+ from flask import (
+ make_response,
+ request,
+ send_from_directory,
+ )
+ uuid = request.args.get('uuid')
+ step_n = int(request.args.get('step_n'))
+
+ watch = datastore.data['watching'].get(uuid)
+ filename = f"step_before-{step_n}.jpeg" if request.args.get('type', '') == 'before' else f"step_{step_n}.jpeg"
+
+ if step_n and watch and os.path.isfile(os.path.join(watch.watch_data_dir, filename)):
+ response = make_response(send_from_directory(directory=watch.watch_data_dir, path=filename))
+ response.headers['Content-type'] = 'image/jpeg'
+ response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
+ response.headers['Pragma'] = 'no-cache'
+ response.headers['Expires'] = 0
+ return response
+
+ else:
+ return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401)
+
+ # A request for an action was received
+ @login_optionally_required
+ @browser_steps_blueprint.route("/browsersteps_update", methods=['POST'])
+ def browsersteps_ui_update():
+ import base64
+ import playwright._impl._errors
+ from changedetectionio.blueprint.browser_steps import browser_steps
+
+ remaining =0
+ uuid = request.args.get('uuid')
+
+ browsersteps_session_id = request.args.get('browsersteps_session_id')
+
+ if not browsersteps_session_id:
+ return make_response('No browsersteps_session_id specified', 500)
+
+ if not browsersteps_sessions.get(browsersteps_session_id):
+ return make_response('No session exists under that ID', 500)
+
+ is_last_step = False
+ # Actions - step/apply/etc, do the thing and return state
+ if request.method == 'POST':
+ # @todo - should always be an existing session
+ step_operation = request.form.get('operation')
+ step_selector = request.form.get('selector')
+ step_optional_value = request.form.get('optional_value')
+ is_last_step = strtobool(request.form.get('is_last_step'))
+
+ try:
+ # Run the async call_action method in the dedicated browser steps event loop
+ run_async_in_browser_loop(
+ browsersteps_sessions[browsersteps_session_id]['browserstepper'].call_action(
+ action_name=step_operation,
+ selector=step_selector,
+ optional_value=step_optional_value
+ )
+ )
+
+ except Exception as e:
+ logger.error(f"Exception when calling step operation {step_operation} {str(e)}")
+ # Try to find something of value to give back to the user
+ return make_response(str(e).splitlines()[0], 401)
+
+
+# if not this_session.page:
+# cleanup_playwright_session()
+# return make_response('Browser session ran out of time :( Please reload this page.', 401)
+
+ # Screenshots and other info only needed on requesting a step (POST)
+ try:
+ # Run the async get_current_state method in the dedicated browser steps event loop
+ (screenshot, xpath_data) = run_async_in_browser_loop(
+ browsersteps_sessions[browsersteps_session_id]['browserstepper'].get_current_state()
+ )
+
+ if is_last_step:
+ watch = datastore.data['watching'].get(uuid)
+ u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url
+ if watch and u:
+ watch.save_screenshot(screenshot=screenshot)
+ watch.save_xpath_data(data=xpath_data)
+
+ except Exception as e:
+ return make_response(f"Error fetching screenshot and element data - {str(e)}", 401)
+
+ # SEND THIS BACK TO THE BROWSER
+ output = {
+ "screenshot": f"data:image/jpeg;base64,{base64.b64encode(screenshot).decode('ascii')}",
+ "xpath_data": xpath_data,
+ "session_age_start": browsersteps_sessions[browsersteps_session_id]['browserstepper'].age_start,
+ "browser_time_remaining": round(remaining)
+ }
+ json_data = json.dumps(output)
+
+ # Generate an ETag (hash of the response body)
+ etag_hash = hashlib.md5(json_data.encode('utf-8')).hexdigest()
+
+ # Create the response with ETag
+ response = Response(json_data, mimetype="application/json; charset=UTF-8")
+ response.set_etag(etag_hash)
+
+ return response
+
+ return browser_steps_blueprint
+
+
diff --git a/changedetectionio/blueprint/browser_steps/browser_steps.py b/changedetectionio/blueprint/browser_steps/browser_steps.py
new file mode 100644
index 0000000000000000000000000000000000000000..03587b3e663cf20aa7000d759953dfbfc5318f6a
--- /dev/null
+++ b/changedetectionio/blueprint/browser_steps/browser_steps.py
@@ -0,0 +1,497 @@
+import os
+import time
+import re
+from random import randint
+from loguru import logger
+
+from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT
+from changedetectionio.content_fetchers.base import manage_user_agent
+from changedetectionio.safe_jinja import render as jinja_render
+
+
+
+# Two flags, tell the JS which of the "Selector" or "Value" field should be enabled in the front end
+# 0- off, 1- on
+browser_step_ui_config = {'Choose one': '0 0',
+ # 'Check checkbox': '1 0',
+ # 'Click button containing text': '0 1',
+ # 'Scroll to bottom': '0 0',
+ # 'Scroll to element': '1 0',
+ # 'Scroll to top': '0 0',
+ # 'Switch to iFrame by index number': '0 1'
+ # 'Uncheck checkbox': '1 0',
+ # @todo
+ 'Check checkbox': '1 0',
+ 'Click X,Y': '0 1',
+ 'Click element if exists': '1 0',
+ 'Click element': '1 0',
+ 'Click element containing text': '0 1',
+ 'Click element containing text if exists': '0 1',
+ 'Enter text in field': '1 1',
+ 'Execute JS': '0 1',
+# 'Extract text and use as filter': '1 0',
+ 'Goto site': '0 0',
+ 'Goto URL': '0 1',
+ 'Make all child elements visible': '1 0',
+ 'Press Enter': '0 0',
+ 'Select by label': '1 1',
+ ' by option text': '1 1',
+ 'Scroll down': '0 0',
+ 'Uncheck checkbox': '1 0',
+ 'Wait for seconds': '0 1',
+ 'Wait for text': '0 1',
+ 'Wait for text in element': '1 1',
+ 'Remove elements': '1 0',
+ # 'Press Page Down': '0 0',
+ # 'Press Page Up': '0 0',
+ # weird bug, come back to it later
+ }
+
+
+# Good reference - https://playwright.dev/python/docs/input
+# https://pythonmana.com/2021/12/202112162236307035.html
+#
+# ONLY Works in Playwright because we need the fullscreen screenshot
+class steppable_browser_interface():
+ page = None
+ start_url = None
+ action_timeout = 10 * 1000
+
+ def __init__(self, start_url):
+ self.start_url = start_url
+
+ # Convert and perform "Click Button" for example
+ async def call_action(self, action_name, selector=None, optional_value=None):
+ if self.page is None:
+ logger.warning("Cannot call action on None page object")
+ return
+
+ now = time.time()
+ call_action_name = re.sub('[^0-9a-zA-Z]+', '_', action_name.lower())
+ if call_action_name == 'choose_one':
+ return
+
+ logger.debug(f"> Action calling '{call_action_name}'")
+ # https://playwright.dev/python/docs/selectors#xpath-selectors
+ if selector and selector.startswith('/') and not selector.startswith('//'):
+ selector = "xpath=" + selector
+
+ # Check if action handler exists
+ if not hasattr(self, "action_" + call_action_name):
+ logger.warning(f"Action handler for '{call_action_name}' not found")
+ return
+
+ action_handler = getattr(self, "action_" + call_action_name)
+
+ # Support for Jinja2 variables in the value and selector
+ if selector and ('{%' in selector or '{{' in selector):
+ selector = jinja_render(template_str=selector)
+
+ if optional_value and ('{%' in optional_value or '{{' in optional_value):
+ optional_value = jinja_render(template_str=optional_value)
+
+ # Trigger click and cautiously handle potential navigation
+ # This means the page redirects/reloads/changes JS etc etc
+ if call_action_name.startswith('click_'):
+ try:
+ # Set up navigation expectation before the click (like sync version)
+ async with self.page.expect_event("framenavigated", timeout=3000) as navigation_info:
+ await action_handler(selector, optional_value)
+
+ # Check if navigation actually occurred
+ try:
+ await navigation_info.value # This waits for the navigation promise
+ logger.debug(f"Navigation occurred on {call_action_name}.")
+ except Exception:
+ logger.debug(f"No navigation occurred within timeout when calling {call_action_name}, that's OK, continuing.")
+
+ except Exception as e:
+ # If expect_event itself times out, that means no navigation occurred - that's OK
+ if "framenavigated" in str(e) and "exceeded" in str(e):
+ logger.debug(f"No navigation occurred within timeout when calling {call_action_name}, that's OK, continuing.")
+ else:
+ raise e
+ else:
+ # Some other action that probably a navigation is not expected
+ await action_handler(selector, optional_value)
+
+
+ # Safely wait for timeout
+ await self.page.wait_for_timeout(1.5 * 1000)
+ logger.debug(f"Call action done in {time.time()-now:.2f}s")
+
+ async def action_goto_url(self, selector=None, value=None):
+ if not value:
+ logger.warning("No URL provided for goto_url action")
+ return None
+
+ now = time.time()
+ response = await self.page.goto(value, timeout=0, wait_until='load')
+ logger.debug(f"Time to goto URL {time.time()-now:.2f}s")
+ return response
+
+ # Incase they request to go back to the start
+ async def action_goto_site(self, selector=None, value=None):
+ return await self.action_goto_url(value=self.start_url)
+
+ async def action_click_element_containing_text(self, selector=None, value=''):
+ logger.debug("Clicking element containing text")
+ if not value or not len(value.strip()):
+ return
+
+ elem = self.page.get_by_text(value)
+ if await elem.count():
+ await elem.first.click(delay=randint(200, 500), timeout=self.action_timeout)
+
+
+ async def action_click_element_containing_text_if_exists(self, selector=None, value=''):
+ logger.debug("Clicking element containing text if exists")
+ if not value or not len(value.strip()):
+ return
+
+ elem = self.page.get_by_text(value)
+ count = await elem.count()
+ logger.debug(f"Clicking element containing text - {count} elements found")
+ if count:
+ await elem.first.click(delay=randint(200, 500), timeout=self.action_timeout)
+
+
+ async def action_enter_text_in_field(self, selector, value):
+ if not selector or not len(selector.strip()):
+ return
+
+ await self.page.fill(selector, value, timeout=self.action_timeout)
+
+ async def action_execute_js(self, selector, value):
+ if not value:
+ return None
+
+ return await self.page.evaluate(value)
+
+ async def action_click_element(self, selector, value):
+ logger.debug("Clicking element")
+ if not selector or not len(selector.strip()):
+ return
+
+ await self.page.click(selector=selector, timeout=self.action_timeout + 20 * 1000, delay=randint(200, 500))
+
+ async def action_click_element_if_exists(self, selector, value):
+ import playwright._impl._errors as _api_types
+ logger.debug("Clicking element if exists")
+ if not selector or not len(selector.strip()):
+ return
+
+ try:
+ await self.page.click(selector, timeout=self.action_timeout, delay=randint(200, 500))
+ except _api_types.TimeoutError:
+ return
+ except _api_types.Error:
+ # Element was there, but page redrew and now its long long gone
+ return
+
+
+ async def action_click_x_y(self, selector, value):
+ if not value or not re.match(r'^\s?\d+\s?,\s?\d+\s?$', value):
+ logger.warning("'Click X,Y' step should be in the format of '100 , 90'")
+ return
+
+ try:
+ x, y = value.strip().split(',')
+ x = int(float(x.strip()))
+ y = int(float(y.strip()))
+
+ await self.page.mouse.click(x=x, y=y, delay=randint(200, 500))
+
+ except Exception as e:
+ logger.error(f"Error parsing x,y coordinates: {str(e)}")
+
+ async def action__select_by_option_text(self, selector, value):
+ if not selector or not len(selector.strip()):
+ return
+
+ await self.page.select_option(selector, label=value, timeout=self.action_timeout)
+
+ async def action_scroll_down(self, selector, value):
+ # Some sites this doesnt work on for some reason
+ await self.page.mouse.wheel(0, 600)
+ await self.page.wait_for_timeout(1000)
+
+ async def action_wait_for_seconds(self, selector, value):
+ try:
+ seconds = float(value.strip()) if value else 1.0
+ await self.page.wait_for_timeout(seconds * 1000)
+ except (ValueError, TypeError) as e:
+ logger.error(f"Invalid value for wait_for_seconds: {str(e)}")
+
+ async def action_wait_for_text(self, selector, value):
+ if not value:
+ return
+
+ import json
+ v = json.dumps(value)
+ await self.page.wait_for_function(
+ f'document.querySelector("body").innerText.includes({v});',
+ timeout=30000
+ )
+
+
+ async def action_wait_for_text_in_element(self, selector, value):
+ if not selector or not value:
+ return
+
+ import json
+ s = json.dumps(selector)
+ v = json.dumps(value)
+
+ await self.page.wait_for_function(
+ f'document.querySelector({s}).innerText.includes({v});',
+ timeout=30000
+ )
+
+ # @todo - in the future make some popout interface to capture what needs to be set
+ # https://playwright.dev/python/docs/api/class-keyboard
+ async def action_press_enter(self, selector, value):
+ await self.page.keyboard.press("Enter", delay=randint(200, 500))
+
+
+ async def action_press_page_up(self, selector, value):
+ await self.page.keyboard.press("PageUp", delay=randint(200, 500))
+
+ async def action_press_page_down(self, selector, value):
+ await self.page.keyboard.press("PageDown", delay=randint(200, 500))
+
+ async def action_check_checkbox(self, selector, value):
+ if not selector:
+ return
+
+ await self.page.locator(selector).check(timeout=self.action_timeout)
+
+ async def action_uncheck_checkbox(self, selector, value):
+ if not selector:
+ return
+
+ await self.page.locator(selector).uncheck(timeout=self.action_timeout)
+
+
+ async def action_remove_elements(self, selector, value):
+ """Removes all elements matching the given selector from the DOM."""
+ if not selector:
+ return
+
+ await self.page.locator(selector).evaluate_all("els => els.forEach(el => el.remove())")
+
+ async def action_make_all_child_elements_visible(self, selector, value):
+ """Recursively makes all child elements inside the given selector fully visible."""
+ if not selector:
+ return
+
+ await self.page.locator(selector).locator("*").evaluate_all("""
+ els => els.forEach(el => {
+ el.style.display = 'block'; // Forces it to be displayed
+ el.style.visibility = 'visible'; // Ensures it's not hidden
+ el.style.opacity = '1'; // Fully opaque
+ el.style.position = 'relative'; // Avoids 'absolute' hiding
+ el.style.height = 'auto'; // Expands collapsed elements
+ el.style.width = 'auto'; // Ensures full visibility
+ el.removeAttribute('hidden'); // Removes hidden attribute
+ el.classList.remove('hidden', 'd-none'); // Removes common CSS hidden classes
+ })
+ """)
+
+# Responsible for maintaining a live 'context' with the chrome CDP
+# @todo - how long do contexts live for anyway?
+class browsersteps_live_ui(steppable_browser_interface):
+ context = None
+ page = None
+ render_extra_delay = 1
+ stale = False
+ # bump and kill this if idle after X sec
+ age_start = 0
+ headers = {}
+ # Track if resources are properly cleaned up
+ _is_cleaned_up = False
+
+ # use a special driver, maybe locally etc
+ command_executor = os.getenv(
+ "PLAYWRIGHT_BROWSERSTEPS_DRIVER_URL"
+ )
+ # if not..
+ if not command_executor:
+ command_executor = os.getenv(
+ "PLAYWRIGHT_DRIVER_URL",
+ 'ws://playwright-chrome:3000'
+ ).strip('"')
+
+ browser_type = os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').strip('"')
+
+ def __init__(self, playwright_browser, proxy=None, headers=None, start_url=None):
+ self.headers = headers or {}
+ self.age_start = time.time()
+ self.playwright_browser = playwright_browser
+ self.start_url = start_url
+ self._is_cleaned_up = False
+ self.proxy = proxy
+ # Note: connect() is now async and must be called separately
+
+ def __del__(self):
+ # Ensure cleanup happens if object is garbage collected
+ # Note: cleanup is now async, so we can only mark as cleaned up here
+ self._is_cleaned_up = True
+
+ # Connect and setup a new context
+ async def connect(self, proxy=None):
+ # Should only get called once - test that
+ keep_open = 1000 * 60 * 5
+ now = time.time()
+
+ # @todo handle multiple contexts, bind a unique id from the browser on each req?
+ self.context = await self.playwright_browser.new_context(
+ accept_downloads=False, # Should never be needed
+ bypass_csp=True, # This is needed to enable JavaScript execution on GitHub and others
+ extra_http_headers=self.headers,
+ ignore_https_errors=True,
+ proxy=proxy,
+ service_workers=os.getenv('PLAYWRIGHT_SERVICE_WORKERS', 'allow'),
+ # Should be `allow` or `block` - sites like YouTube can transmit large amounts of data via Service Workers
+ user_agent=manage_user_agent(headers=self.headers),
+ )
+
+ self.page = await self.context.new_page()
+
+ # self.page.set_default_navigation_timeout(keep_open)
+ self.page.set_default_timeout(keep_open)
+ # Set event handlers
+ self.page.on("close", self.mark_as_closed)
+ # Listen for all console events and handle errors
+ self.page.on("console", lambda msg: print(f"Browser steps console - {msg.type}: {msg.text} {msg.args}"))
+
+ logger.debug(f"Time to browser setup {time.time()-now:.2f}s")
+ await self.page.wait_for_timeout(1 * 1000)
+
+ def mark_as_closed(self):
+ logger.debug("Page closed, cleaning up..")
+ # Note: This is called from a sync context (event handler)
+ # so we'll just mark as cleaned up and let __del__ handle the rest
+ self._is_cleaned_up = True
+
+ async def cleanup(self):
+ """Properly clean up all resources to prevent memory leaks"""
+ if self._is_cleaned_up:
+ return
+
+ logger.debug("Cleaning up browser steps resources")
+
+ # Clean up page
+ if hasattr(self, 'page') and self.page is not None:
+ try:
+ # Force garbage collection before closing
+ await self.page.request_gc()
+ except Exception as e:
+ logger.debug(f"Error during page garbage collection: {str(e)}")
+
+ try:
+ # Remove event listeners before closing
+ self.page.remove_listener("close", self.mark_as_closed)
+ except Exception as e:
+ logger.debug(f"Error removing event listeners: {str(e)}")
+
+ try:
+ await self.page.close()
+ except Exception as e:
+ logger.debug(f"Error closing page: {str(e)}")
+
+ self.page = None
+
+ # Clean up context
+ if hasattr(self, 'context') and self.context is not None:
+ try:
+ await self.context.close()
+ except Exception as e:
+ logger.debug(f"Error closing context: {str(e)}")
+
+ self.context = None
+
+ self._is_cleaned_up = True
+ logger.debug("Browser steps resources cleanup complete")
+
+ @property
+ def has_expired(self):
+ if not self.page or self._is_cleaned_up:
+ return True
+
+ # Check if session has expired based on age
+ max_age_seconds = int(os.getenv("BROWSER_STEPS_MAX_AGE_SECONDS", 60 * 10)) # Default 10 minutes
+ if (time.time() - self.age_start) > max_age_seconds:
+ logger.debug(f"Browser steps session expired after {max_age_seconds} seconds")
+ return True
+
+ return False
+
+ async def get_current_state(self):
+ """Return the screenshot and interactive elements mapping, generally always called after action_()"""
+ import importlib.resources
+ import json
+ # because we for now only run browser steps in playwright mode (not puppeteer mode)
+ from changedetectionio.content_fetchers.playwright import capture_full_page_async
+
+ # Safety check - don't proceed if resources are cleaned up
+ if self._is_cleaned_up or self.page is None:
+ logger.warning("Attempted to get current state after cleanup")
+ return (None, None)
+
+ xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text()
+
+ now = time.time()
+ await self.page.wait_for_timeout(1 * 1000)
+
+ screenshot = None
+ xpath_data = None
+
+ try:
+ # Get screenshot first
+ screenshot = await capture_full_page_async(page=self.page)
+ if not screenshot:
+ logger.error("No screenshot was retrieved :((")
+
+ logger.debug(f"Time to get screenshot from browser {time.time() - now:.2f}s")
+
+ # Then get interactive elements
+ now = time.time()
+ await self.page.evaluate("var include_filters=''")
+ await self.page.request_gc()
+
+ scan_elements = 'a,button,input,select,textarea,i,th,td,p,li,h1,h2,h3,h4,div,span'
+
+ MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
+ xpath_data = json.loads(await self.page.evaluate(xpath_element_js, {
+ "visualselector_xpath_selectors": scan_elements,
+ "max_height": MAX_TOTAL_HEIGHT
+ }))
+ await self.page.request_gc()
+
+ # Sort elements by size
+ xpath_data['size_pos'] = sorted(xpath_data['size_pos'], key=lambda k: k['width'] * k['height'], reverse=True)
+ logger.debug(f"Time to scrape xPath element data in browser {time.time()-now:.2f}s")
+
+ except Exception as e:
+ logger.error(f"Error getting current state: {str(e)}")
+ # If the page has navigated (common with logins) then the context is destroyed on navigation, continue
+ # I'm not sure that this is required anymore because we have the "expect navigation wrapper" at the top
+ if "Execution context was destroyed" in str(e):
+ logger.debug("Execution context was destroyed, most likely because of navigation, continuing...")
+ pass
+
+ # Attempt recovery - force garbage collection
+ try:
+ await self.page.request_gc()
+ except:
+ pass
+
+ # Request garbage collection one final time
+ try:
+ await self.page.request_gc()
+ except:
+ pass
+
+ return (screenshot, xpath_data)
+
diff --git a/changedetectionio/blueprint/check_proxies/__init__.py b/changedetectionio/blueprint/check_proxies/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..28fe5eba12760c475d5cbd461d226b1d0d9d7c3d
--- /dev/null
+++ b/changedetectionio/blueprint/check_proxies/__init__.py
@@ -0,0 +1,124 @@
+import importlib
+from concurrent.futures import ThreadPoolExecutor
+
+from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
+from changedetectionio.store import ChangeDetectionStore
+
+from functools import wraps
+
+from flask import Blueprint
+from flask_login import login_required
+
+STATUS_CHECKING = 0
+STATUS_FAILED = 1
+STATUS_OK = 2
+THREADPOOL_MAX_WORKERS = 3
+_DEFAULT_POOL = ThreadPoolExecutor(max_workers=THREADPOOL_MAX_WORKERS)
+
+
+# Maybe use fetch-time if its >5 to show some expected load time?
+def threadpool(f, executor=None):
+ @wraps(f)
+ def wrap(*args, **kwargs):
+ return (executor or _DEFAULT_POOL).submit(f, *args, **kwargs)
+
+ return wrap
+
+
+def construct_blueprint(datastore: ChangeDetectionStore):
+ check_proxies_blueprint = Blueprint('check_proxies', __name__)
+ checks_in_progress = {}
+
+ @threadpool
+ def long_task(uuid, preferred_proxy):
+ import time
+ from changedetectionio.content_fetchers import exceptions as content_fetcher_exceptions
+ from changedetectionio.safe_jinja import render as jinja_render
+
+ status = {'status': '', 'length': 0, 'text': ''}
+
+ contents = ''
+ now = time.time()
+ try:
+ processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor")
+ update_handler = processor_module.perform_site_check(datastore=datastore,
+ watch_uuid=uuid
+ )
+
+ update_handler.call_browser(preferred_proxy_id=preferred_proxy)
+ # title, size is len contents not len xfer
+ except content_fetcher_exceptions.Non200ErrorCodeReceived as e:
+ if e.status_code == 404:
+ status.update({'status': 'OK', 'length': len(contents), 'text': f"OK but 404 (page not found)"})
+ elif e.status_code == 403 or e.status_code == 401:
+ status.update({'status': 'ERROR', 'length': len(contents), 'text': f"{e.status_code} - Access denied"})
+ else:
+ status.update({'status': 'ERROR', 'length': len(contents), 'text': f"Status code: {e.status_code}"})
+ except FilterNotFoundInResponse:
+ status.update({'status': 'OK', 'length': len(contents), 'text': f"OK but CSS/xPath filter not found (page changed layout?)"})
+ except content_fetcher_exceptions.EmptyReply as e:
+ if e.status_code == 403 or e.status_code == 401:
+ status.update({'status': 'ERROR OTHER', 'length': len(contents), 'text': f"Got empty reply with code {e.status_code} - Access denied"})
+ else:
+ status.update({'status': 'ERROR OTHER', 'length': len(contents) if contents else 0, 'text': f"Empty reply with code {e.status_code}, needs chrome?"})
+ except content_fetcher_exceptions.ReplyWithContentButNoText as e:
+ txt = f"Got reply but with no content - Status code {e.status_code} - It's possible that the filters were found, but contained no usable text (or contained only an image)."
+ status.update({'status': 'ERROR', 'text': txt})
+ except Exception as e:
+ status.update({'status': 'ERROR OTHER', 'length': len(contents) if contents else 0, 'text': 'Error: '+type(e).__name__+str(e)})
+ else:
+ status.update({'status': 'OK', 'length': len(contents), 'text': ''})
+
+ if status.get('text'):
+ # parse 'text' as text for safety
+ v = {'text': status['text']}
+ status['text'] = jinja_render(template_str='{{text|e}}', **v)
+
+ status['time'] = "{:.2f}s".format(time.time() - now)
+
+ return status
+
+ def _recalc_check_status(uuid):
+
+ results = {}
+ for k, v in checks_in_progress.get(uuid, {}).items():
+ try:
+ r_1 = v.result(timeout=0.05)
+ except Exception as e:
+ # If timeout error?
+ results[k] = {'status': 'RUNNING'}
+
+ else:
+ results[k] = r_1
+
+ return results
+
+ @login_required
+ @check_proxies_blueprint.route("//status", methods=['GET'])
+ def get_recheck_status(uuid):
+ results = _recalc_check_status(uuid=uuid)
+ return results
+
+ @login_required
+ @check_proxies_blueprint.route("//start", methods=['GET'])
+ def start_check(uuid):
+
+ if not datastore.proxy_list:
+ return
+
+ if checks_in_progress.get(uuid):
+ state = _recalc_check_status(uuid=uuid)
+ for proxy_key, v in state.items():
+ if v.get('status') == 'RUNNING':
+ return state
+ else:
+ checks_in_progress[uuid] = {}
+
+ for k, v in datastore.proxy_list.items():
+ if not checks_in_progress[uuid].get(k):
+ checks_in_progress[uuid][k] = long_task(uuid=uuid, preferred_proxy=k)
+
+ results = _recalc_check_status(uuid=uuid)
+ return results
+
+ return check_proxies_blueprint
diff --git a/changedetectionio/blueprint/imports/__init__.py b/changedetectionio/blueprint/imports/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6fbf760fdf5c9c51d965d2b3a269753b67bc04b
--- /dev/null
+++ b/changedetectionio/blueprint/imports/__init__.py
@@ -0,0 +1,75 @@
+from flask import Blueprint, request, redirect, url_for, flash, render_template
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.auth_decorator import login_optionally_required
+from changedetectionio import worker_handler
+from changedetectionio.blueprint.imports.importer import (
+ import_url_list,
+ import_distill_io_json,
+ import_xlsx_wachete,
+ import_xlsx_custom
+)
+
+def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
+ import_blueprint = Blueprint('imports', __name__, template_folder="templates")
+
+ @import_blueprint.route("/import", methods=['GET', 'POST'])
+ @login_optionally_required
+ def import_page():
+ remaining_urls = []
+ from changedetectionio import forms
+
+ if request.method == 'POST':
+ # URL List import
+ if request.values.get('urls') and len(request.values.get('urls').strip()):
+ # Import and push into the queue for immediate update check
+ importer_handler = import_url_list()
+ importer_handler.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff'))
+ for uuid in importer_handler.new_uuids:
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
+
+ if len(importer_handler.remaining_data) == 0:
+ return redirect(url_for('watchlist.index'))
+ else:
+ remaining_urls = importer_handler.remaining_data
+
+ # Distill.io import
+ if request.values.get('distill-io') and len(request.values.get('distill-io').strip()):
+ # Import and push into the queue for immediate update check
+ d_importer = import_distill_io_json()
+ d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore)
+ for uuid in d_importer.new_uuids:
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
+
+ # XLSX importer
+ if request.files and request.files.get('xlsx_file'):
+ file = request.files['xlsx_file']
+
+ if request.values.get('file_mapping') == 'wachete':
+ w_importer = import_xlsx_wachete()
+ w_importer.run(data=file, flash=flash, datastore=datastore)
+ else:
+ w_importer = import_xlsx_custom()
+ # Building mapping of col # to col # type
+ map = {}
+ for i in range(10):
+ c = request.values.get(f"custom_xlsx[col_{i}]")
+ v = request.values.get(f"custom_xlsx[col_type_{i}]")
+ if c and v:
+ map[int(c)] = v
+
+ w_importer.import_profile = map
+ w_importer.run(data=file, flash=flash, datastore=datastore)
+
+ for uuid in w_importer.new_uuids:
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
+
+ # Could be some remaining, or we could be on GET
+ form = forms.importForm(formdata=request.form if request.method == 'POST' else None)
+ output = render_template("import.html",
+ form=form,
+ import_url_list_remaining="\n".join(remaining_urls),
+ original_distill_json=''
+ )
+ return output
+
+ return import_blueprint
\ No newline at end of file
diff --git a/changedetectionio/blueprint/imports/importer.py b/changedetectionio/blueprint/imports/importer.py
new file mode 100644
index 0000000000000000000000000000000000000000..4824d13890e3a0041d88125ba00828664dcc396e
--- /dev/null
+++ b/changedetectionio/blueprint/imports/importer.py
@@ -0,0 +1,302 @@
+from abc import abstractmethod
+import time
+from wtforms import ValidationError
+from loguru import logger
+
+from changedetectionio.forms import validate_url
+
+
+class Importer():
+ remaining_data = []
+ new_uuids = []
+ good = 0
+
+ def __init__(self):
+ self.new_uuids = []
+ self.good = 0
+ self.remaining_data = []
+ self.import_profile = None
+
+ @abstractmethod
+ def run(self,
+ data,
+ flash,
+ datastore):
+ pass
+
+
+class import_url_list(Importer):
+ """
+ Imports a list, can be in https://example.com tag1, tag2, last tag format
+ """
+ def run(self,
+ data,
+ flash,
+ datastore,
+ processor=None
+ ):
+
+ urls = data.split("\n")
+ good = 0
+ now = time.time()
+
+ if (len(urls) > 5000):
+ flash("Importing 5,000 of the first URLs from your list, the rest can be imported again.")
+
+ for url in urls:
+ url = url.strip()
+ if not len(url):
+ continue
+
+ tags = ""
+
+ # 'tags' should be a csv list after the URL
+ if ' ' in url:
+ url, tags = url.split(" ", 1)
+
+ # Flask wtform validators wont work with basic auth, use validators package
+ # Up to 5000 per batch so we dont flood the server
+ # @todo validators.url will fail when you add your own IP etc
+ if len(url) and 'http' in url.lower() and good < 5000:
+ extras = None
+ if processor:
+ extras = {'processor': processor}
+ new_uuid = datastore.add_watch(url=url.strip(), tag=tags, write_to_disk_now=False, extras=extras)
+
+ if new_uuid:
+ # Straight into the queue.
+ self.new_uuids.append(new_uuid)
+ good += 1
+ continue
+
+ # Worked past the 'continue' above, append it to the bad list
+ if self.remaining_data is None:
+ self.remaining_data = []
+ self.remaining_data.append(url)
+
+ flash("{} Imported from list in {:.2f}s, {} Skipped.".format(good, time.time() - now, len(self.remaining_data)))
+
+
+class import_distill_io_json(Importer):
+ def run(self,
+ data,
+ flash,
+ datastore,
+ ):
+
+ import json
+ good = 0
+ now = time.time()
+ self.new_uuids=[]
+
+ # @todo Use JSONSchema like in the API to validate here.
+
+ try:
+ data = json.loads(data.strip())
+ except json.decoder.JSONDecodeError:
+ flash("Unable to read JSON file, was it broken?", 'error')
+ return
+
+ if not data.get('data'):
+ flash("JSON structure looks invalid, was it broken?", 'error')
+ return
+
+ for d in data.get('data'):
+ d_config = json.loads(d['config'])
+ extras = {'title': d.get('name', None)}
+
+ if len(d['uri']) and good < 5000:
+ try:
+ # @todo we only support CSS ones at the moment
+ if d_config['selections'][0]['frames'][0]['excludes'][0]['type'] == 'css':
+ extras['subtractive_selectors'] = d_config['selections'][0]['frames'][0]['excludes'][0]['expr']
+ except KeyError:
+ pass
+ except IndexError:
+ pass
+ extras['include_filters'] = []
+ try:
+ if d_config['selections'][0]['frames'][0]['includes'][0]['type'] == 'xpath':
+ extras['include_filters'].append('xpath:' + d_config['selections'][0]['frames'][0]['includes'][0]['expr'])
+ else:
+ extras['include_filters'].append(d_config['selections'][0]['frames'][0]['includes'][0]['expr'])
+ except KeyError:
+ pass
+ except IndexError:
+ pass
+
+ new_uuid = datastore.add_watch(url=d['uri'].strip(),
+ tag=",".join(d.get('tags', [])),
+ extras=extras,
+ write_to_disk_now=False)
+
+ if new_uuid:
+ # Straight into the queue.
+ self.new_uuids.append(new_uuid)
+ good += 1
+
+ flash("{} Imported from Distill.io in {:.2f}s, {} Skipped.".format(len(self.new_uuids), time.time() - now, len(self.remaining_data)))
+
+
+class import_xlsx_wachete(Importer):
+
+ def run(self,
+ data,
+ flash,
+ datastore,
+ ):
+
+ good = 0
+ now = time.time()
+ self.new_uuids = []
+
+ from openpyxl import load_workbook
+
+ try:
+ wb = load_workbook(data)
+ except Exception as e:
+ # @todo correct except
+ flash("Unable to read export XLSX file, something wrong with the file?", 'error')
+ return
+
+ row_id = 2
+ for row in wb.active.iter_rows(min_row=row_id):
+ try:
+ extras = {}
+ data = {}
+ for cell in row:
+ if not cell.value:
+ continue
+ column_title = wb.active.cell(row=1, column=cell.column).value.strip().lower()
+ data[column_title] = cell.value
+
+ # Forced switch to webdriver/playwright/etc
+ dynamic_wachet = str(data.get('dynamic wachet', '')).strip().lower() # Convert bool to str to cover all cases
+ # libreoffice and others can have it as =FALSE() =TRUE(), or bool(true)
+ if 'true' in dynamic_wachet or dynamic_wachet == '1':
+ extras['fetch_backend'] = 'html_webdriver'
+ elif 'false' in dynamic_wachet or dynamic_wachet == '0':
+ extras['fetch_backend'] = 'html_requests'
+
+ if data.get('xpath'):
+ # @todo split by || ?
+ extras['include_filters'] = [data.get('xpath')]
+ if data.get('name'):
+ extras['title'] = data.get('name').strip()
+ if data.get('interval (min)'):
+ minutes = int(data.get('interval (min)'))
+ hours, minutes = divmod(minutes, 60)
+ days, hours = divmod(hours, 24)
+ weeks, days = divmod(days, 7)
+ extras['time_between_check'] = {'weeks': weeks, 'days': days, 'hours': hours, 'minutes': minutes, 'seconds': 0}
+
+ # At minimum a URL is required.
+ if data.get('url'):
+ try:
+ validate_url(data.get('url'))
+ except ValidationError as e:
+ logger.error(f">> Import URL error {data.get('url')} {str(e)}")
+ flash(f"Error processing row number {row_id}, URL value was incorrect, row was skipped.", 'error')
+ # Don't bother processing anything else on this row
+ continue
+
+ new_uuid = datastore.add_watch(url=data['url'].strip(),
+ extras=extras,
+ tag=data.get('folder'),
+ write_to_disk_now=False)
+ if new_uuid:
+ # Straight into the queue.
+ self.new_uuids.append(new_uuid)
+ good += 1
+ except Exception as e:
+ logger.error(e)
+ flash(f"Error processing row number {row_id}, check all cell data types are correct, row was skipped.", 'error')
+ else:
+ row_id += 1
+
+ flash(
+ "{} imported from Wachete .xlsx in {:.2f}s".format(len(self.new_uuids), time.time() - now))
+
+
+class import_xlsx_custom(Importer):
+
+ def run(self,
+ data,
+ flash,
+ datastore,
+ ):
+
+ good = 0
+ now = time.time()
+ self.new_uuids = []
+
+ from openpyxl import load_workbook
+
+ try:
+ wb = load_workbook(data)
+ except Exception as e:
+ # @todo correct except
+ flash("Unable to read export XLSX file, something wrong with the file?", 'error')
+ return
+
+ # @todo cehck atleast 2 rows, same in other method
+ from changedetectionio.forms import validate_url
+ row_i = 1
+
+ try:
+ for row in wb.active.iter_rows():
+ url = None
+ tags = None
+ extras = {}
+
+ for cell in row:
+ if not self.import_profile.get(cell.col_idx):
+ continue
+ if not cell.value:
+ continue
+
+ cell_map = self.import_profile.get(cell.col_idx)
+
+ cell_val = str(cell.value).strip() # could be bool
+
+ if cell_map == 'url':
+ url = cell.value.strip()
+ try:
+ validate_url(url)
+ except ValidationError as e:
+ logger.error(f">> Import URL error {url} {str(e)}")
+ flash(f"Error processing row number {row_i}, URL value was incorrect, row was skipped.", 'error')
+ # Don't bother processing anything else on this row
+ url = None
+ break
+ elif cell_map == 'tag':
+ tags = cell.value.strip()
+ elif cell_map == 'include_filters':
+ # @todo validate?
+ extras['include_filters'] = [cell.value.strip()]
+ elif cell_map == 'interval_minutes':
+ hours, minutes = divmod(int(cell_val), 60)
+ days, hours = divmod(hours, 24)
+ weeks, days = divmod(days, 7)
+ extras['time_between_check'] = {'weeks': weeks, 'days': days, 'hours': hours, 'minutes': minutes, 'seconds': 0}
+ else:
+ extras[cell_map] = cell_val
+
+ # At minimum a URL is required.
+ if url:
+ new_uuid = datastore.add_watch(url=url,
+ extras=extras,
+ tag=tags,
+ write_to_disk_now=False)
+ if new_uuid:
+ # Straight into the queue.
+ self.new_uuids.append(new_uuid)
+ good += 1
+ except Exception as e:
+ logger.error(e)
+ flash(f"Error processing row number {row_i}, check all cell data types are correct, row was skipped.", 'error')
+ else:
+ row_i += 1
+
+ flash(
+ "{} imported from custom .xlsx in {:.2f}s".format(len(self.new_uuids), time.time() - now))
\ No newline at end of file
diff --git a/changedetectionio/blueprint/imports/templates/import.html b/changedetectionio/blueprint/imports/templates/import.html
new file mode 100644
index 0000000000000000000000000000000000000000..fbd7a4b6528285832c1bf8013e864591c3353df8
--- /dev/null
+++ b/changedetectionio/blueprint/imports/templates/import.html
@@ -0,0 +1,123 @@
+{% extends 'base.html' %}
+{% block content %}
+{% from '_helpers.html' import render_field %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/changedetectionio/blueprint/price_data_follower/__init__.py b/changedetectionio/blueprint/price_data_follower/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c2c6e768c8a7347e76556ee826d4fcd1b959d06c
--- /dev/null
+++ b/changedetectionio/blueprint/price_data_follower/__init__.py
@@ -0,0 +1,35 @@
+
+from changedetectionio.strtobool import strtobool
+from flask import Blueprint, flash, redirect, url_for
+from flask_login import login_required
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio import queuedWatchMetaData
+from changedetectionio import worker_handler
+from queue import PriorityQueue
+
+PRICE_DATA_TRACK_ACCEPT = 'accepted'
+PRICE_DATA_TRACK_REJECT = 'rejected'
+
+def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue):
+
+ price_data_follower_blueprint = Blueprint('price_data_follower', __name__)
+
+ @login_required
+ @price_data_follower_blueprint.route("//accept", methods=['GET'])
+ def accept(uuid):
+ datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT
+ datastore.data['watching'][uuid]['processor'] = 'restock_diff'
+ datastore.data['watching'][uuid].clear_watch()
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
+ return redirect(url_for("watchlist.index"))
+
+ @login_required
+ @price_data_follower_blueprint.route("//reject", methods=['GET'])
+ def reject(uuid):
+ datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_REJECT
+ return redirect(url_for("watchlist.index"))
+
+
+ return price_data_follower_blueprint
+
+
diff --git a/changedetectionio/blueprint/rss/__init__.py b/changedetectionio/blueprint/rss/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d4e091948d710ceb473130f5c1c616188643a65b
--- /dev/null
+++ b/changedetectionio/blueprint/rss/__init__.py
@@ -0,0 +1 @@
+RSS_FORMAT_TYPES = [('plaintext', 'Plain text'), ('html', 'HTML Color')]
diff --git a/changedetectionio/blueprint/rss/blueprint.py b/changedetectionio/blueprint/rss/blueprint.py
new file mode 100644
index 0000000000000000000000000000000000000000..edaa5b1ec8a4b70b0ad5cc1b521a1a19d3c46777
--- /dev/null
+++ b/changedetectionio/blueprint/rss/blueprint.py
@@ -0,0 +1,147 @@
+
+from changedetectionio.safe_jinja import render as jinja_render
+from changedetectionio.store import ChangeDetectionStore
+from feedgen.feed import FeedGenerator
+from flask import Blueprint, make_response, request, url_for, redirect
+from loguru import logger
+import datetime
+import pytz
+import re
+import time
+
+
+BAD_CHARS_REGEX=r'[\x00-\x08\x0B\x0C\x0E-\x1F]'
+
+# Anything that is not text/UTF-8 should be stripped before it breaks feedgen (such as binary data etc)
+def scan_invalid_chars_in_rss(content):
+ for match in re.finditer(BAD_CHARS_REGEX, content):
+ i = match.start()
+ bad_char = content[i]
+ hex_value = f"0x{ord(bad_char):02x}"
+ # Grab context
+ start = max(0, i - 20)
+ end = min(len(content), i + 21)
+ context = content[start:end].replace('\n', '\\n').replace('\r', '\\r')
+ logger.warning(f"Invalid char {hex_value} at pos {i}: ...{context}...")
+ # First match is enough
+ return True
+
+ return False
+
+
+def clean_entry_content(content):
+ cleaned = re.sub(BAD_CHARS_REGEX, '', content)
+ return cleaned
+
+def construct_blueprint(datastore: ChangeDetectionStore):
+ rss_blueprint = Blueprint('rss', __name__)
+
+ # Some RSS reader situations ended up with rss/ (forward slash after RSS) due
+ # to some earlier blueprint rerouting work, it should goto feed.
+ @rss_blueprint.route("/", methods=['GET'])
+ def extraslash():
+ return redirect(url_for('rss.feed'))
+
+ # Import the login decorator if needed
+ # from changedetectionio.auth_decorator import login_optionally_required
+ @rss_blueprint.route("", methods=['GET'])
+ def feed():
+ now = time.time()
+ # Always requires token set
+ app_rss_token = datastore.data['settings']['application'].get('rss_access_token')
+ rss_url_token = request.args.get('token')
+ if rss_url_token != app_rss_token:
+ return "Access denied, bad token", 403
+
+ from changedetectionio import diff
+ limit_tag = request.args.get('tag', '').lower().strip()
+ # Be sure limit_tag is a uuid
+ for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
+ if limit_tag == tag.get('title', '').lower().strip():
+ limit_tag = uuid
+
+ # Sort by last_changed and add the uuid which is usually the key..
+ sorted_watches = []
+
+ # @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away
+ for uuid, watch in datastore.data['watching'].items():
+ # @todo tag notification_muted skip also (improve Watch model)
+ if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'):
+ continue
+ if limit_tag and not limit_tag in watch['tags']:
+ continue
+ watch['uuid'] = uuid
+ sorted_watches.append(watch)
+
+ sorted_watches.sort(key=lambda x: x.last_changed, reverse=False)
+
+ fg = FeedGenerator()
+ fg.title('changedetection.io')
+ fg.description('Feed description')
+ fg.link(href='https://changedetection.io')
+
+ html_colour_enable = False
+ if datastore.data['settings']['application'].get('rss_content_format') == 'html':
+ html_colour_enable = True
+
+ for watch in sorted_watches:
+
+ dates = list(watch.history.keys())
+ # Re #521 - Don't bother processing this one if theres less than 2 snapshots, means we never had a change detected.
+ if len(dates) < 2:
+ continue
+
+ if not watch.viewed:
+ # Re #239 - GUID needs to be individual for each event
+ # @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228)
+ guid = "{}/{}".format(watch['uuid'], watch.last_changed)
+ fe = fg.add_entry()
+
+ # Include a link to the diff page, they will have to login here to see if password protection is enabled.
+ # Description is the page you watch, link takes you to the diff JS UI page
+ # Dict val base_url will get overriden with the env var if it is set.
+ ext_base_url = datastore.data['settings']['application'].get('active_base_url')
+ # @todo fix
+
+ # Because we are called via whatever web server, flask should figure out the right path (
+ diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)}
+
+ fe.link(link=diff_link)
+
+ # @todo watch should be a getter - watch.get('title') (internally if URL else..)
+
+ watch_title = watch.get('title') if watch.get('title') else watch.get('url')
+ fe.title(title=watch_title)
+ try:
+
+ html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]),
+ newest_version_file_contents=watch.get_history_snapshot(dates[-1]),
+ include_equal=False,
+ line_feed_sep=" ",
+ html_colour=html_colour_enable
+ )
+ except FileNotFoundError as e:
+ html_diff = f"History snapshot file for watch {watch.get('uuid')}@{watch.last_changed} - '{watch.get('title')} not found."
+
+ # @todo Make this configurable and also consider html-colored markup
+ # @todo User could decide if goes to the diff page, or to the watch link
+ rss_template = "\n\n{{html_diff}}
\n\n"
+
+ content = jinja_render(template_str=rss_template, watch_title=watch_title, html_diff=html_diff, watch_url=watch.link)
+
+ # Out of range chars could also break feedgen
+ if scan_invalid_chars_in_rss(content):
+ content = clean_entry_content(content)
+
+ fe.content(content=content, type='CDATA')
+ fe.guid(guid, permalink=False)
+ dt = datetime.datetime.fromtimestamp(int(watch.newest_history_key))
+ dt = dt.replace(tzinfo=pytz.UTC)
+ fe.pubDate(dt)
+
+ response = make_response(fg.rss_str())
+ response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8')
+ logger.trace(f"RSS generated in {time.time() - now:.3f}s")
+ return response
+
+ return rss_blueprint
\ No newline at end of file
diff --git a/changedetectionio/blueprint/settings/__init__.py b/changedetectionio/blueprint/settings/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..548a5b701f98898f049317d16ace8041eebfb56e
--- /dev/null
+++ b/changedetectionio/blueprint/settings/__init__.py
@@ -0,0 +1,145 @@
+import os
+from copy import deepcopy
+from datetime import datetime
+from zoneinfo import ZoneInfo, available_timezones
+import secrets
+import flask_login
+from flask import Blueprint, render_template, request, redirect, url_for, flash
+
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.auth_decorator import login_optionally_required
+
+
+def construct_blueprint(datastore: ChangeDetectionStore):
+ settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
+
+ @settings_blueprint.route("", methods=['GET', "POST"])
+ @login_optionally_required
+ def settings_page():
+ from changedetectionio import forms
+
+ default = deepcopy(datastore.data['settings'])
+ if datastore.proxy_list is not None:
+ available_proxies = list(datastore.proxy_list.keys())
+ # When enabled
+ system_proxy = datastore.data['settings']['requests']['proxy']
+ # In the case it doesnt exist anymore
+ if not system_proxy in available_proxies:
+ system_proxy = None
+
+ default['requests']['proxy'] = system_proxy if system_proxy is not None else available_proxies[0]
+ # Used by the form handler to keep or remove the proxy settings
+ default['proxy_list'] = available_proxies[0]
+
+ # Don't use form.data on POST so that it doesnt overrid the checkbox status from the POST status
+ form = forms.globalSettingsForm(formdata=request.form if request.method == 'POST' else None,
+ data=default,
+ extra_notification_tokens=datastore.get_unique_notification_tokens_available()
+ )
+
+ # Remove the last option 'System default'
+ form.application.form.notification_format.choices.pop()
+
+ if datastore.proxy_list is None:
+ # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead
+ del form.requests.form.proxy
+ else:
+ form.requests.form.proxy.choices = []
+ for p in datastore.proxy_list:
+ form.requests.form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label'])))
+
+ if request.method == 'POST':
+ # Password unset is a GET, but we can lock the session to a salted env password to always need the password
+ if form.application.form.data.get('removepassword_button', False):
+ # SALTED_PASS means the password is "locked" to what we set in the Env var
+ if not os.getenv("SALTED_PASS", False):
+ datastore.remove_password()
+ flash("Password protection removed.", 'notice')
+ flask_login.logout_user()
+ return redirect(url_for('settings.settings_page'))
+
+ if form.validate():
+ # Don't set password to False when a password is set - should be only removed with the `removepassword` button
+ app_update = dict(deepcopy(form.data['application']))
+
+ # Never update password with '' or False (Added by wtforms when not in submission)
+ if 'password' in app_update and not app_update['password']:
+ del (app_update['password'])
+
+ datastore.data['settings']['application'].update(app_update)
+
+ # Handle dynamic worker count adjustment
+ old_worker_count = datastore.data['settings']['requests'].get('workers', 1)
+ new_worker_count = form.data['requests'].get('workers', 1)
+
+ datastore.data['settings']['requests'].update(form.data['requests'])
+
+ # Adjust worker count if it changed
+ if new_worker_count != old_worker_count:
+ from changedetectionio import worker_handler
+ from changedetectionio.flask_app import update_q, notification_q, app, datastore as ds
+
+ result = worker_handler.adjust_async_worker_count(
+ new_count=new_worker_count,
+ update_q=update_q,
+ notification_q=notification_q,
+ app=app,
+ datastore=ds
+ )
+
+ if result['status'] == 'success':
+ flash(f"Worker count adjusted: {result['message']}", 'notice')
+ elif result['status'] == 'not_supported':
+ flash("Dynamic worker adjustment not supported for sync workers", 'warning')
+ elif result['status'] == 'error':
+ flash(f"Error adjusting workers: {result['message']}", 'error')
+
+ if not os.getenv("SALTED_PASS", False) and len(form.application.form.password.encrypted_password):
+ datastore.data['settings']['application']['password'] = form.application.form.password.encrypted_password
+ datastore.needs_write_urgent = True
+ flash("Password protection enabled.", 'notice')
+ flask_login.logout_user()
+ return redirect(url_for('watchlist.index'))
+
+ datastore.needs_write_urgent = True
+ flash("Settings updated.")
+
+ else:
+ flash("An error occurred, please see below.", "error")
+
+ # Convert to ISO 8601 format, all date/time relative events stored as UTC time
+ utc_time = datetime.now(ZoneInfo("UTC")).isoformat()
+
+ output = render_template("settings.html",
+ api_key=datastore.data['settings']['application'].get('api_access_token'),
+ available_timezones=sorted(available_timezones()),
+ emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
+ extra_notification_token_placeholder_info=datastore.get_unique_notification_token_placeholders_available(),
+ form=form,
+ hide_remove_pass=os.getenv("SALTED_PASS", False),
+ min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)),
+ settings_application=datastore.data['settings']['application'],
+ timezone_default_config=datastore.data['settings']['application'].get('timezone'),
+ utc_time=utc_time,
+ )
+
+ return output
+
+ @settings_blueprint.route("/reset-api-key", methods=['GET'])
+ @login_optionally_required
+ def settings_reset_api_key():
+ secret = secrets.token_hex(16)
+ datastore.data['settings']['application']['api_access_token'] = secret
+ datastore.needs_write_urgent = True
+ flash("API Key was regenerated.")
+ return redirect(url_for('settings.settings_page')+'#api')
+
+ @settings_blueprint.route("/notification-logs", methods=['GET'])
+ @login_optionally_required
+ def notification_logs():
+ from changedetectionio.flask_app import notification_debug_log
+ output = render_template("notification-log.html",
+ logs=notification_debug_log if len(notification_debug_log) else ["Notification logs are empty - no notifications sent yet."])
+ return output
+
+ return settings_blueprint
\ No newline at end of file
diff --git a/changedetectionio/blueprint/settings/templates/notification-log.html b/changedetectionio/blueprint/settings/templates/notification-log.html
new file mode 100644
index 0000000000000000000000000000000000000000..ee76e2590be2f0c639aecce4a9f2f5a98a8692c6
--- /dev/null
+++ b/changedetectionio/blueprint/settings/templates/notification-log.html
@@ -0,0 +1,19 @@
+{% extends 'base.html' %}
+
+{% block content %}
+
+
+{% endblock %}
diff --git a/changedetectionio/blueprint/settings/templates/settings.html b/changedetectionio/blueprint/settings/templates/settings.html
new file mode 100644
index 0000000000000000000000000000000000000000..88ebd6de77b32a3a7525429215522d5c74d909bd
--- /dev/null
+++ b/changedetectionio/blueprint/settings/templates/settings.html
@@ -0,0 +1,330 @@
+{% extends 'base.html' %}
+
+{% block content %}
+{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form %}
+{% from '_common_fields.html' import render_common_settings_form %}
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/changedetectionio/blueprint/tags/README.md b/changedetectionio/blueprint/tags/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..c61159c2a91381bd8d3a5d3c65e3f1fa36121389
--- /dev/null
+++ b/changedetectionio/blueprint/tags/README.md
@@ -0,0 +1,9 @@
+# Groups tags
+
+## How it works
+
+Watch has a list() of tag UUID's, which relate to a config under application.settings.tags
+
+The 'tag' is actually a watch, because they basically will eventually share 90% of the same config.
+
+So a tag is like an abstract of a watch
diff --git a/changedetectionio/blueprint/tags/__init__.py b/changedetectionio/blueprint/tags/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0a987e26c5c5b3be687a3e12ca56b1aec3bf64aa
--- /dev/null
+++ b/changedetectionio/blueprint/tags/__init__.py
@@ -0,0 +1,191 @@
+from flask import Blueprint, request, render_template, flash, url_for, redirect
+
+
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.flask_app import login_optionally_required
+
+
+def construct_blueprint(datastore: ChangeDetectionStore):
+ tags_blueprint = Blueprint('tags', __name__, template_folder="templates")
+
+ @tags_blueprint.route("/list", methods=['GET'])
+ @login_optionally_required
+ def tags_overview_page():
+ from .form import SingleTag
+ add_form = SingleTag(request.form)
+
+ sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
+
+ from collections import Counter
+
+ tag_count = Counter(tag for watch in datastore.data['watching'].values() if watch.get('tags') for tag in watch['tags'])
+
+ output = render_template("groups-overview.html",
+ available_tags=sorted_tags,
+ form=add_form,
+ tag_count=tag_count
+ )
+
+ return output
+
+ @tags_blueprint.route("/add", methods=['POST'])
+ @login_optionally_required
+ def form_tag_add():
+ from .form import SingleTag
+ add_form = SingleTag(request.form)
+
+ if not add_form.validate():
+ for widget, l in add_form.errors.items():
+ flash(','.join(l), 'error')
+ return redirect(url_for('tags.tags_overview_page'))
+
+ title = request.form.get('name').strip()
+
+ if datastore.tag_exists_by_name(title):
+ flash(f'The tag "{title}" already exists', "error")
+ return redirect(url_for('tags.tags_overview_page'))
+
+ datastore.add_tag(title)
+ flash("Tag added")
+
+
+ return redirect(url_for('tags.tags_overview_page'))
+
+ @tags_blueprint.route("/mute/", methods=['GET'])
+ @login_optionally_required
+ def mute(uuid):
+ if datastore.data['settings']['application']['tags'].get(uuid):
+ datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = not datastore.data['settings']['application']['tags'][uuid]['notification_muted']
+ return redirect(url_for('tags.tags_overview_page'))
+
+ @tags_blueprint.route("/delete/", methods=['GET'])
+ @login_optionally_required
+ def delete(uuid):
+ removed = 0
+ # Delete the tag, and any tag reference
+ if datastore.data['settings']['application']['tags'].get(uuid):
+ del datastore.data['settings']['application']['tags'][uuid]
+
+ for watch_uuid, watch in datastore.data['watching'].items():
+ if watch.get('tags') and uuid in watch['tags']:
+ removed += 1
+ watch['tags'].remove(uuid)
+
+ flash(f"Tag deleted and removed from {removed} watches")
+ return redirect(url_for('tags.tags_overview_page'))
+
+ @tags_blueprint.route("/unlink/", methods=['GET'])
+ @login_optionally_required
+ def unlink(uuid):
+ unlinked = 0
+ for watch_uuid, watch in datastore.data['watching'].items():
+ if watch.get('tags') and uuid in watch['tags']:
+ unlinked += 1
+ watch['tags'].remove(uuid)
+
+ flash(f"Tag unlinked removed from {unlinked} watches")
+ return redirect(url_for('tags.tags_overview_page'))
+
+ @tags_blueprint.route("/delete_all", methods=['GET'])
+ @login_optionally_required
+ def delete_all():
+ for watch_uuid, watch in datastore.data['watching'].items():
+ watch['tags'] = []
+ datastore.data['settings']['application']['tags'] = {}
+
+ flash(f"All tags deleted")
+ return redirect(url_for('tags.tags_overview_page'))
+
+ @tags_blueprint.route("/edit/", methods=['GET'])
+ @login_optionally_required
+ def form_tag_edit(uuid):
+ from changedetectionio.blueprint.tags.form import group_restock_settings_form
+ if uuid == 'first':
+ uuid = list(datastore.data['settings']['application']['tags'].keys()).pop()
+
+ default = datastore.data['settings']['application']['tags'].get(uuid)
+ if not default:
+ flash("Tag not found", "error")
+ return redirect(url_for('watchlist.index'))
+
+ form = group_restock_settings_form(
+ formdata=request.form if request.method == 'POST' else None,
+ data=default,
+ extra_notification_tokens=datastore.get_unique_notification_tokens_available(),
+ default_system_settings = datastore.data['settings'],
+ )
+
+ template_args = {
+ 'data': default,
+ 'form': form,
+ 'watch': default,
+ 'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
+ }
+
+ included_content = {}
+ if form.extra_form_content():
+ # So that the extra panels can access _helpers.html etc, we set the environment to load from templates/
+ # And then render the code from the module
+ from jinja2 import Environment, FileSystemLoader
+ import importlib.resources
+ templates_dir = str(importlib.resources.files("changedetectionio").joinpath('templates'))
+ env = Environment(loader=FileSystemLoader(templates_dir))
+ template_str = """{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
+
+
+
+
+ {{ render_checkbox_field(form.overrides_watch) }}
+ Used for watches in "Restock & Price detection" mode
+
+
+ """
+ template_str += form.extra_form_content()
+ template = env.from_string(template_str)
+ included_content = template.render(**template_args)
+
+ output = render_template("edit-tag.html",
+ settings_application=datastore.data['settings']['application'],
+ extra_tab_content=form.extra_tab_content() if form.extra_tab_content() else None,
+ extra_form_content=included_content,
+ **template_args
+ )
+
+ return output
+
+
+ @tags_blueprint.route("/edit/
", methods=['POST'])
+ @login_optionally_required
+ def form_tag_edit_submit(uuid):
+ from changedetectionio.blueprint.tags.form import group_restock_settings_form
+ if uuid == 'first':
+ uuid = list(datastore.data['settings']['application']['tags'].keys()).pop()
+
+ default = datastore.data['settings']['application']['tags'].get(uuid)
+
+ form = group_restock_settings_form(formdata=request.form if request.method == 'POST' else None,
+ data=default,
+ extra_notification_tokens=datastore.get_unique_notification_tokens_available()
+ )
+ # @todo subclass form so validation works
+ #if not form.validate():
+# for widget, l in form.errors.items():
+# flash(','.join(l), 'error')
+# return redirect(url_for('tags.form_tag_edit_submit', uuid=uuid))
+
+ datastore.data['settings']['application']['tags'][uuid].update(form.data)
+ datastore.data['settings']['application']['tags'][uuid]['processor'] = 'restock_diff'
+ datastore.needs_write_urgent = True
+ flash("Updated")
+
+ return redirect(url_for('tags.tags_overview_page'))
+
+
+ @tags_blueprint.route("/delete/", methods=['GET'])
+ def form_tag_delete(uuid):
+ return redirect(url_for('tags.tags_overview_page'))
+ return tags_blueprint
diff --git a/changedetectionio/blueprint/tags/form.py b/changedetectionio/blueprint/tags/form.py
new file mode 100644
index 0000000000000000000000000000000000000000..6ff3a503ca40a7356b5abea8114343998dcdb5cc
--- /dev/null
+++ b/changedetectionio/blueprint/tags/form.py
@@ -0,0 +1,21 @@
+from wtforms import (
+ Form,
+ StringField,
+ SubmitField,
+ validators,
+)
+from wtforms.fields.simple import BooleanField
+
+from changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form
+
+class group_restock_settings_form(restock_settings_form):
+ overrides_watch = BooleanField('Activate for individual watches in this tag/group?', default=False)
+
+class SingleTag(Form):
+
+ name = StringField('Tag name', [validators.InputRequired()], render_kw={"placeholder": "Name"})
+ save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})
+
+
+
+
diff --git a/changedetectionio/blueprint/tags/templates/edit-tag.html b/changedetectionio/blueprint/tags/templates/edit-tag.html
new file mode 100644
index 0000000000000000000000000000000000000000..0ddf73b267c5d6389a1772086f62d327288bf75b
--- /dev/null
+++ b/changedetectionio/blueprint/tags/templates/edit-tag.html
@@ -0,0 +1,100 @@
+{% extends 'base.html' %}
+{% block content %}
+{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
+{% from '_common_fields.html' import render_common_settings_form %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ render_field(form.title, placeholder="https://...", required=true, class="m-d") }}
+
+
+
+
+
+
These settings are added to any existing watch configurations.
+ {% include "edit/include_subtract.html" %}
+
+
Text filtering
+ {% include "edit/text-options.html" %}
+
+
+
+ {# rendered sub Template #}
+ {% if extra_form_content %}
+
+ {% endif %}
+
+
+
+ {{ render_checkbox_field(form.notification_muted) }}
+
+ {% if 1 %}
+
+ {{ render_checkbox_field(form.notification_screenshot) }}
+
+ Use with caution! This will easily fill up your email storage quota or flood other storages.
+
+
+ {% endif %}
+
+ {% if has_default_notification_urls %}
+
+
+ There are
system-wide notification URLs enabled , this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications.
+
+ {% endif %}
+
Use system defaults
+
+ {{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
+
+
+
+
+
+
+ {{ render_button(form.save_button) }}
+
+
+
+
+
+
+{% endblock %}
diff --git a/changedetectionio/blueprint/tags/templates/groups-overview.html b/changedetectionio/blueprint/tags/templates/groups-overview.html
new file mode 100644
index 0000000000000000000000000000000000000000..b75762f57d489ee3b301b6cc2b628a1c37afc96c
--- /dev/null
+++ b/changedetectionio/blueprint/tags/templates/groups-overview.html
@@ -0,0 +1,62 @@
+{% extends 'base.html' %}
+{% block content %}
+{% from '_helpers.html' import render_simple_field, render_field %}
+
+
+
+
+
+
+ Add a new organisational tag
+
+
+ {{ render_simple_field(form.name, placeholder="watch label / tag") }}
+
+
+ {{ render_simple_field(form.save_button, title="Save" ) }}
+
+
+
+ Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.
+
+
+
+
+
+
+
+
+
+ # Watches
+ Tag / Label name
+
+
+
+
+
+ {% if not available_tags|length %}
+
+ No website organisational tags/groups configured
+
+ {% endif %}
+ {% for uuid, tag in available_tags %}
+
+
+
+
+ {{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}
+ {{ tag.title }}
+
+ Edit
+ Delete
+ Unlink
+
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/changedetectionio/blueprint/ui/__init__.py b/changedetectionio/blueprint/ui/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..52488684956bc597e9ba3012df2b502dc590c542
--- /dev/null
+++ b/changedetectionio/blueprint/ui/__init__.py
@@ -0,0 +1,317 @@
+import time
+from flask import Blueprint, request, redirect, url_for, flash, render_template, session
+from loguru import logger
+
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.blueprint.ui.edit import construct_blueprint as construct_edit_blueprint
+from changedetectionio.blueprint.ui.notification import construct_blueprint as construct_notification_blueprint
+from changedetectionio.blueprint.ui.views import construct_blueprint as construct_views_blueprint
+
+def _handle_operations(op, uuids, datastore, worker_handler, update_q, queuedWatchMetaData, watch_check_update, extra_data=None, emit_flash=True):
+ from flask import request, flash
+
+ if op == 'delete':
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ datastore.delete(uuid)
+ if emit_flash:
+ flash(f"{len(uuids)} watches deleted")
+
+ elif op == 'pause':
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ datastore.data['watching'][uuid]['paused'] = True
+ if emit_flash:
+ flash(f"{len(uuids)} watches paused")
+
+ elif op == 'unpause':
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ datastore.data['watching'][uuid.strip()]['paused'] = False
+ if emit_flash:
+ flash(f"{len(uuids)} watches unpaused")
+
+ elif (op == 'mark-viewed'):
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ datastore.set_last_viewed(uuid, int(time.time()))
+ if emit_flash:
+ flash(f"{len(uuids)} watches updated")
+
+ elif (op == 'mute'):
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ datastore.data['watching'][uuid]['notification_muted'] = True
+ if emit_flash:
+ flash(f"{len(uuids)} watches muted")
+
+ elif (op == 'unmute'):
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ datastore.data['watching'][uuid]['notification_muted'] = False
+ if emit_flash:
+ flash(f"{len(uuids)} watches un-muted")
+
+ elif (op == 'recheck'):
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ # Recheck and require a full reprocessing
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
+ if emit_flash:
+ flash(f"{len(uuids)} watches queued for rechecking")
+
+ elif (op == 'clear-errors'):
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ datastore.data['watching'][uuid]["last_error"] = False
+ if emit_flash:
+ flash(f"{len(uuids)} watches errors cleared")
+
+ elif (op == 'clear-history'):
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ datastore.clear_watch_history(uuid)
+ if emit_flash:
+ flash(f"{len(uuids)} watches cleared/reset.")
+
+ elif (op == 'notification-default'):
+ from changedetectionio.notification import (
+ default_notification_format_for_watch
+ )
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ datastore.data['watching'][uuid]['notification_title'] = None
+ datastore.data['watching'][uuid]['notification_body'] = None
+ datastore.data['watching'][uuid]['notification_urls'] = []
+ datastore.data['watching'][uuid]['notification_format'] = default_notification_format_for_watch
+ if emit_flash:
+ flash(f"{len(uuids)} watches set to use default notification settings")
+
+ elif (op == 'assign-tag'):
+ op_extradata = extra_data
+ if op_extradata:
+ tag_uuid = datastore.add_tag(title=op_extradata)
+ if op_extradata and tag_uuid:
+ for uuid in uuids:
+ if datastore.data['watching'].get(uuid):
+ # Bug in old versions caused by bad edit page/tag handler
+ if isinstance(datastore.data['watching'][uuid]['tags'], str):
+ datastore.data['watching'][uuid]['tags'] = []
+
+ datastore.data['watching'][uuid]['tags'].append(tag_uuid)
+ if emit_flash:
+ flash(f"{len(uuids)} watches were tagged")
+
+ if uuids:
+ for uuid in uuids:
+ watch_check_update.send(watch_uuid=uuid)
+
+def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handler, queuedWatchMetaData, watch_check_update):
+ ui_blueprint = Blueprint('ui', __name__, template_folder="templates")
+
+ # Register the edit blueprint
+ edit_blueprint = construct_edit_blueprint(datastore, update_q, queuedWatchMetaData)
+ ui_blueprint.register_blueprint(edit_blueprint)
+
+ # Register the notification blueprint
+ notification_blueprint = construct_notification_blueprint(datastore)
+ ui_blueprint.register_blueprint(notification_blueprint)
+
+ # Register the views blueprint
+ views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData, watch_check_update)
+ ui_blueprint.register_blueprint(views_blueprint)
+
+ # Import the login decorator
+ from changedetectionio.auth_decorator import login_optionally_required
+
+ @ui_blueprint.route("/clear_history/", methods=['GET'])
+ @login_optionally_required
+ def clear_watch_history(uuid):
+ try:
+ datastore.clear_watch_history(uuid)
+ except KeyError:
+ flash('Watch not found', 'error')
+ else:
+ flash("Cleared snapshot history for watch {}".format(uuid))
+ return redirect(url_for('watchlist.index'))
+
+ @ui_blueprint.route("/clear_history", methods=['GET', 'POST'])
+ @login_optionally_required
+ def clear_all_history():
+ if request.method == 'POST':
+ confirmtext = request.form.get('confirmtext')
+
+ if confirmtext == 'clear':
+ for uuid in datastore.data['watching'].keys():
+ datastore.clear_watch_history(uuid)
+ flash("Cleared snapshot history for all watches")
+ else:
+ flash('Incorrect confirmation text.', 'error')
+
+ return redirect(url_for('watchlist.index'))
+
+ output = render_template("clear_all_history.html")
+ return output
+
+ # Clear all statuses, so we do not see the 'unviewed' class
+ @ui_blueprint.route("/form/mark-all-viewed", methods=['GET'])
+ @login_optionally_required
+ def mark_all_viewed():
+ # Save the current newest history as the most recently viewed
+ with_errors = request.args.get('with_errors') == "1"
+ for watch_uuid, watch in datastore.data['watching'].items():
+ if with_errors and not watch.get('last_error'):
+ continue
+ datastore.set_last_viewed(watch_uuid, int(time.time()))
+
+ return redirect(url_for('watchlist.index'))
+
+ @ui_blueprint.route("/delete", methods=['GET'])
+ @login_optionally_required
+ def form_delete():
+ uuid = request.args.get('uuid')
+
+ if uuid != 'all' and not uuid in datastore.data['watching'].keys():
+ flash('The watch by UUID {} does not exist.'.format(uuid), 'error')
+ return redirect(url_for('watchlist.index'))
+
+ # More for testing, possible to return the first/only
+ if uuid == 'first':
+ uuid = list(datastore.data['watching'].keys()).pop()
+ datastore.delete(uuid)
+ flash('Deleted.')
+
+ return redirect(url_for('watchlist.index'))
+
+ @ui_blueprint.route("/clone", methods=['GET'])
+ @login_optionally_required
+ def form_clone():
+ uuid = request.args.get('uuid')
+ # More for testing, possible to return the first/only
+ if uuid == 'first':
+ uuid = list(datastore.data['watching'].keys()).pop()
+
+ new_uuid = datastore.clone(uuid)
+
+ if not datastore.data['watching'].get(uuid).get('paused'):
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid}))
+
+ flash('Cloned, you are editing the new watch.')
+
+ return redirect(url_for("ui.ui_edit.edit_page", uuid=new_uuid))
+
+ @ui_blueprint.route("/checknow", methods=['GET'])
+ @login_optionally_required
+ def form_watch_checknow():
+ # Forced recheck will skip the 'skip if content is the same' rule (, 'reprocess_existing_data': True})))
+ tag = request.args.get('tag')
+ uuid = request.args.get('uuid')
+ with_errors = request.args.get('with_errors') == "1"
+
+ i = 0
+
+ running_uuids = worker_handler.get_running_uuids()
+
+ if uuid:
+ if uuid not in running_uuids:
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
+ i += 1
+
+ else:
+ # Recheck all, including muted
+ # Get most overdue first
+ for k in sorted(datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)):
+ watch_uuid = k[0]
+ watch = k[1]
+ if not watch['paused']:
+ if watch_uuid not in running_uuids:
+ if with_errors and not watch.get('last_error'):
+ continue
+
+ if tag != None and tag not in watch['tags']:
+ continue
+
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
+ i += 1
+
+ if i == 1:
+ flash("Queued 1 watch for rechecking.")
+ if i > 1:
+ flash(f"Queued {i} watches for rechecking.")
+ if i == 0:
+ flash("No watches available to recheck.")
+
+ return redirect(url_for('watchlist.index'))
+
+ @ui_blueprint.route("/form/checkbox-operations", methods=['POST'])
+ @login_optionally_required
+ def form_watch_list_checkbox_operations():
+ op = request.form['op']
+ uuids = [u.strip() for u in request.form.getlist('uuids') if u]
+ extra_data = request.form.get('op_extradata', '').strip()
+ _handle_operations(
+ datastore=datastore,
+ extra_data=extra_data,
+ queuedWatchMetaData=queuedWatchMetaData,
+ uuids=uuids,
+ worker_handler=worker_handler,
+ update_q=update_q,
+ watch_check_update=watch_check_update,
+ op=op,
+ )
+
+ return redirect(url_for('watchlist.index'))
+
+
+ @ui_blueprint.route("/share-url/", methods=['GET'])
+ @login_optionally_required
+ def form_share_put_watch(uuid):
+ """Given a watch UUID, upload the info and return a share-link
+ the share-link can be imported/added"""
+ import requests
+ import json
+ from copy import deepcopy
+
+ # more for testing
+ if uuid == 'first':
+ uuid = list(datastore.data['watching'].keys()).pop()
+
+ # copy it to memory as trim off what we dont need (history)
+ watch = deepcopy(datastore.data['watching'].get(uuid))
+ # For older versions that are not a @property
+ if (watch.get('history')):
+ del (watch['history'])
+
+ # for safety/privacy
+ for k in list(watch.keys()):
+ if k.startswith('notification_'):
+ del watch[k]
+
+ for r in['uuid', 'last_checked', 'last_changed']:
+ if watch.get(r):
+ del (watch[r])
+
+ # Add the global stuff which may have an impact
+ watch['ignore_text'] += datastore.data['settings']['application']['global_ignore_text']
+ watch['subtractive_selectors'] += datastore.data['settings']['application']['global_subtractive_selectors']
+
+ watch_json = json.dumps(watch)
+
+ try:
+ r = requests.request(method="POST",
+ data={'watch': watch_json},
+ url="https://changedetection.io/share/share",
+ headers={'App-Guid': datastore.data['app_guid']})
+ res = r.json()
+
+ # Add to the flask session
+ session['share-link'] = f"https://changedetection.io/share/{res['share_key']}"
+
+
+ except Exception as e:
+ logger.error(f"Error sharing -{str(e)}")
+ flash(f"Could not share, something went wrong while communicating with the share server - {str(e)}", 'error')
+
+ return redirect(url_for('watchlist.index'))
+
+ return ui_blueprint
\ No newline at end of file
diff --git a/changedetectionio/blueprint/ui/edit.py b/changedetectionio/blueprint/ui/edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..bdee47256254c2d18684284bd23e2faa68f65916
--- /dev/null
+++ b/changedetectionio/blueprint/ui/edit.py
@@ -0,0 +1,339 @@
+import time
+from copy import deepcopy
+import os
+import importlib.resources
+from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory, abort
+from loguru import logger
+from jinja2 import Environment, FileSystemLoader
+
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.auth_decorator import login_optionally_required
+from changedetectionio.time_handler import is_within_schedule
+from changedetectionio import worker_handler
+
+def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
+ edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates")
+
+ def _watch_has_tag_options_set(watch):
+ """This should be fixed better so that Tag is some proper Model, a tag is just a Watch also"""
+ for tag_uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
+ if tag_uuid in watch.get('tags', []) and (tag.get('include_filters') or tag.get('subtractive_selectors')):
+ return True
+
+ @edit_blueprint.route("/edit/", methods=['GET', 'POST'])
+ @login_optionally_required
+ # https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists
+ # https://wtforms.readthedocs.io/en/3.0.x/forms/#wtforms.form.Form.populate_obj ?
+ def edit_page(uuid):
+ from changedetectionio import forms
+ from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config
+ from changedetectionio import processors
+ import importlib
+
+ # More for testing, possible to return the first/only
+ if not datastore.data['watching'].keys():
+ flash("No watches to edit", "error")
+ return redirect(url_for('watchlist.index'))
+
+ if uuid == 'first':
+ uuid = list(datastore.data['watching'].keys()).pop()
+
+ if not uuid in datastore.data['watching']:
+ flash("No watch with the UUID %s found." % (uuid), "error")
+ return redirect(url_for('watchlist.index'))
+
+ switch_processor = request.args.get('switch_processor')
+ if switch_processor:
+ for p in processors.available_processors():
+ if p[0] == switch_processor:
+ datastore.data['watching'][uuid]['processor'] = switch_processor
+ flash(f"Switched to mode - {p[1]}.")
+ datastore.clear_watch_history(uuid)
+ redirect(url_for('ui_edit.edit_page', uuid=uuid))
+
+ # be sure we update with a copy instead of accidently editing the live object by reference
+ default = deepcopy(datastore.data['watching'][uuid])
+
+ # Defaults for proxy choice
+ if datastore.proxy_list is not None: # When enabled
+ # @todo
+ # Radio needs '' not None, or incase that the chosen one no longer exists
+ if default['proxy'] is None or not any(default['proxy'] in tup for tup in datastore.proxy_list):
+ default['proxy'] = ''
+ # proxy_override set to the json/text list of the items
+
+ # Does it use some custom form? does one exist?
+ processor_name = datastore.data['watching'][uuid].get('processor', '')
+ processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == processor_name), None)
+ if not processor_classes:
+ flash(f"Cannot load the edit form for processor/plugin '{processor_classes[1]}', plugin missing?", 'error')
+ return redirect(url_for('watchlist.index'))
+
+ parent_module = processors.get_parent_module(processor_classes[0])
+
+ try:
+ # Get the parent of the "processor.py" go up one, get the form (kinda spaghetti but its reusing existing code)
+ forms_module = importlib.import_module(f"{parent_module.__name__}.forms")
+ # Access the 'processor_settings_form' class from the 'forms' module
+ form_class = getattr(forms_module, 'processor_settings_form')
+ except ModuleNotFoundError as e:
+ # .forms didnt exist
+ form_class = forms.processor_text_json_diff_form
+ except AttributeError as e:
+ # .forms exists but no useful form
+ form_class = forms.processor_text_json_diff_form
+
+ form = form_class(formdata=request.form if request.method == 'POST' else None,
+ data=default,
+ extra_notification_tokens=default.extra_notification_token_values(),
+ default_system_settings=datastore.data['settings']
+ )
+
+ # For the form widget tag UUID back to "string name" for the field
+ form.tags.datastore = datastore
+
+ # Used by some forms that need to dig deeper
+ form.datastore = datastore
+ form.watch = default
+
+ for p in datastore.extra_browsers:
+ form.fetch_backend.choices.append(p)
+
+ form.fetch_backend.choices.append(("system", 'System settings default'))
+
+ # form.browser_steps[0] can be assumed that we 'goto url' first
+
+ if datastore.proxy_list is None:
+ # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead
+ del form.proxy
+ else:
+ form.proxy.choices = [('', 'Default')]
+ for p in datastore.proxy_list:
+ form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label'])))
+
+
+ if request.method == 'POST' and form.validate():
+
+ # If they changed processor, it makes sense to reset it.
+ if datastore.data['watching'][uuid].get('processor') != form.data.get('processor'):
+ datastore.data['watching'][uuid].clear_watch()
+ flash("Reset watch history due to change of processor")
+
+ extra_update_obj = {
+ 'consecutive_filter_failures': 0,
+ 'last_error' : False
+ }
+
+ if request.args.get('unpause_on_save'):
+ extra_update_obj['paused'] = False
+
+ extra_update_obj['time_between_check'] = form.time_between_check.data
+
+ # Ignore text
+ form_ignore_text = form.ignore_text.data
+ datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text
+
+ # Be sure proxy value is None
+ if datastore.proxy_list is not None and form.data['proxy'] == '':
+ extra_update_obj['proxy'] = None
+
+ # Unsetting all filter_text methods should make it go back to default
+ # This particularly affects tests running
+ if 'filter_text_added' in form.data and not form.data.get('filter_text_added') \
+ and 'filter_text_replaced' in form.data and not form.data.get('filter_text_replaced') \
+ and 'filter_text_removed' in form.data and not form.data.get('filter_text_removed'):
+ extra_update_obj['filter_text_added'] = True
+ extra_update_obj['filter_text_replaced'] = True
+ extra_update_obj['filter_text_removed'] = True
+
+ # Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs
+ tag_uuids = []
+ if form.data.get('tags'):
+ # Sometimes in testing this can be list, dont know why
+ if type(form.data.get('tags')) == list:
+ extra_update_obj['tags'] = form.data.get('tags')
+ else:
+ for t in form.data.get('tags').split(','):
+ tag_uuids.append(datastore.add_tag(title=t))
+ extra_update_obj['tags'] = tag_uuids
+
+ datastore.data['watching'][uuid].update(form.data)
+ datastore.data['watching'][uuid].update(extra_update_obj)
+
+ if not datastore.data['watching'][uuid].get('tags'):
+ # Force it to be a list, because form.data['tags'] will be string if nothing found
+ # And del(form.data['tags'] ) wont work either for some reason
+ datastore.data['watching'][uuid]['tags'] = []
+
+ # Recast it if need be to right data Watch handler
+ watch_class = processors.get_custom_watch_obj_for_processor(form.data.get('processor'))
+ datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid])
+ flash("Updated watch - unpaused!" if request.args.get('unpause_on_save') else "Updated watch.")
+
+ # Re #286 - We wait for syncing new data to disk in another thread every 60 seconds
+ # But in the case something is added we should save straight away
+ datastore.needs_write_urgent = True
+
+ # Do not queue on edit if its not within the time range
+
+ # @todo maybe it should never queue anyway on edit...
+ is_in_schedule = True
+ watch = datastore.data['watching'].get(uuid)
+
+ if watch.get('time_between_check_use_default'):
+ time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})
+ else:
+ time_schedule_limit = watch.get('time_schedule_limit')
+
+ tz_name = time_schedule_limit.get('timezone')
+ if not tz_name:
+ tz_name = datastore.data['settings']['application'].get('timezone', 'UTC')
+
+ if time_schedule_limit and time_schedule_limit.get('enabled'):
+ try:
+ is_in_schedule = is_within_schedule(time_schedule_limit=time_schedule_limit,
+ default_tz=tz_name
+ )
+ except Exception as e:
+ logger.error(
+ f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}")
+ return False
+
+ #############################
+ if not datastore.data['watching'][uuid].get('paused') and is_in_schedule:
+ # Queue the watch for immediate recheck, with a higher priority
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
+
+ # Diff page [edit] link should go back to diff page
+ if request.args.get("next") and request.args.get("next") == 'diff':
+ return redirect(url_for('ui.ui_views.diff_history_page', uuid=uuid))
+
+ return redirect(url_for('watchlist.index', tag=request.args.get("tag",'')))
+
+ else:
+ if request.method == 'POST' and not form.validate():
+ flash("An error occurred, please see below.", "error")
+
+ # JQ is difficult to install on windows and must be manually added (outside requirements.txt)
+ jq_support = True
+ try:
+ import jq
+ except ModuleNotFoundError:
+ jq_support = False
+
+ watch = datastore.data['watching'].get(uuid)
+
+ # if system or watch is configured to need a chrome type browser
+ system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
+ watch_needs_selenium_or_playwright = False
+ if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
+ watch_needs_selenium_or_playwright = True
+
+
+ from zoneinfo import available_timezones
+
+ # Only works reliably with Playwright
+
+ # Import the global plugin system
+ from changedetectionio.pluggy_interface import collect_ui_edit_stats_extras
+
+ template_args = {
+ 'available_processors': processors.available_processors(),
+ 'available_timezones': sorted(available_timezones()),
+ 'browser_steps_config': browser_step_ui_config,
+ 'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
+ 'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
+ 'extra_processor_config': form.extra_tab_content(),
+ 'extra_title': f" - Edit - {watch.label}",
+ 'form': form,
+ 'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False,
+ 'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,
+ 'has_special_tag_options': _watch_has_tag_options_set(watch=watch),
+ 'jq_support': jq_support,
+ 'playwright_enabled': os.getenv('PLAYWRIGHT_DRIVER_URL', False),
+ 'settings_application': datastore.data['settings']['application'],
+ 'system_has_playwright_configured': os.getenv('PLAYWRIGHT_DRIVER_URL'),
+ 'system_has_webdriver_configured': os.getenv('WEBDRIVER_URL'),
+ 'ui_edit_stats_extras': collect_ui_edit_stats_extras(watch),
+ 'visual_selector_data_ready': datastore.visualselector_data_is_ready(watch_uuid=uuid),
+ 'timezone_default_config': datastore.data['settings']['application'].get('timezone'),
+ 'using_global_webdriver_wait': not default['webdriver_delay'],
+ 'uuid': uuid,
+ 'watch': watch,
+ 'watch_needs_selenium_or_playwright': watch_needs_selenium_or_playwright,
+ }
+
+ included_content = None
+ if form.extra_form_content():
+ # So that the extra panels can access _helpers.html etc, we set the environment to load from templates/
+ # And then render the code from the module
+ templates_dir = str(importlib.resources.files("changedetectionio").joinpath('templates'))
+ env = Environment(loader=FileSystemLoader(templates_dir))
+ template = env.from_string(form.extra_form_content())
+ included_content = template.render(**template_args)
+
+ output = render_template("edit.html",
+ extra_tab_content=form.extra_tab_content() if form.extra_tab_content() else None,
+ extra_form_content=included_content,
+ **template_args
+ )
+
+ return output
+
+ @edit_blueprint.route("/edit//get-html", methods=['GET'])
+ @login_optionally_required
+ def watch_get_latest_html(uuid):
+ from io import BytesIO
+ from flask import send_file
+ import brotli
+
+ watch = datastore.data['watching'].get(uuid)
+ if watch and watch.history.keys() and os.path.isdir(watch.watch_data_dir):
+ latest_filename = list(watch.history.keys())[-1]
+ html_fname = os.path.join(watch.watch_data_dir, f"{latest_filename}.html.br")
+ with open(html_fname, 'rb') as f:
+ if html_fname.endswith('.br'):
+ # Read and decompress the Brotli file
+ decompressed_data = brotli.decompress(f.read())
+ else:
+ decompressed_data = f.read()
+
+ buffer = BytesIO(decompressed_data)
+
+ return send_file(buffer, as_attachment=True, download_name=f"{latest_filename}.html", mimetype='text/html')
+
+ # Return a 500 error
+ abort(500)
+
+ # Ajax callback
+ @edit_blueprint.route("/edit//preview-rendered", methods=['POST'])
+ @login_optionally_required
+ def watch_get_preview_rendered(uuid):
+ '''For when viewing the "preview" of the rendered text from inside of Edit'''
+ from flask import jsonify
+ from changedetectionio.processors.text_json_diff import prepare_filter_prevew
+ result = prepare_filter_prevew(watch_uuid=uuid, form_data=request.form, datastore=datastore)
+ return jsonify(result)
+
+ @edit_blueprint.route("/highlight_submit_ignore_url", methods=['POST'])
+ @login_optionally_required
+ def highlight_submit_ignore_url():
+ import re
+ mode = request.form.get('mode')
+ selection = request.form.get('selection')
+
+ uuid = request.args.get('uuid','')
+ if datastore.data["watching"].get(uuid):
+ if mode == 'exact':
+ for l in selection.splitlines():
+ datastore.data["watching"][uuid]['ignore_text'].append(l.strip())
+ elif mode == 'digit-regex':
+ for l in selection.splitlines():
+ # Replace any series of numbers with a regex
+ s = re.escape(l.strip())
+ s = re.sub(r'[0-9]+', r'\\d+', s)
+ datastore.data["watching"][uuid]['ignore_text'].append('/' + s + '/')
+
+ return f"Click to preview "
+
+ return edit_blueprint
\ No newline at end of file
diff --git a/changedetectionio/blueprint/ui/notification.py b/changedetectionio/blueprint/ui/notification.py
new file mode 100644
index 0000000000000000000000000000000000000000..f20fb52715b2855f144b110226b242fbb1ed3f75
--- /dev/null
+++ b/changedetectionio/blueprint/ui/notification.py
@@ -0,0 +1,108 @@
+from flask import Blueprint, request, make_response
+import random
+from loguru import logger
+
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.auth_decorator import login_optionally_required
+
+def construct_blueprint(datastore: ChangeDetectionStore):
+ notification_blueprint = Blueprint('ui_notification', __name__, template_folder="../ui/templates")
+
+ # AJAX endpoint for sending a test
+ @notification_blueprint.route("/notification/send-test/", methods=['POST'])
+ @notification_blueprint.route("/notification/send-test", methods=['POST'])
+ @notification_blueprint.route("/notification/send-test/", methods=['POST'])
+ @login_optionally_required
+ def ajax_callback_send_notification_test(watch_uuid=None):
+
+ # Watch_uuid could be unset in the case it`s used in tag editor, global settings
+ import apprise
+ from changedetectionio.notification.handler import process_notification
+ from changedetectionio.notification.apprise_plugin.assets import apprise_asset
+
+ from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler
+
+ apobj = apprise.Apprise(asset=apprise_asset)
+
+ is_global_settings_form = request.args.get('mode', '') == 'global-settings'
+ is_group_settings_form = request.args.get('mode', '') == 'group-settings'
+
+ # Use an existing random one on the global/main settings form
+ if not watch_uuid and (is_global_settings_form or is_group_settings_form) \
+ and datastore.data.get('watching'):
+ logger.debug(f"Send test notification - Choosing random Watch {watch_uuid}")
+ watch_uuid = random.choice(list(datastore.data['watching'].keys()))
+
+ if not watch_uuid:
+ return make_response("Error: You must have atleast one watch configured for 'test notification' to work", 400)
+
+ watch = datastore.data['watching'].get(watch_uuid)
+
+ notification_urls = None
+
+ if request.form.get('notification_urls'):
+ notification_urls = request.form['notification_urls'].strip().splitlines()
+
+ if not notification_urls:
+ logger.debug("Test notification - Trying by group/tag in the edit form if available")
+ # On an edit page, we should also fire off to the tags if they have notifications
+ if request.form.get('tags') and request.form['tags'].strip():
+ for k in request.form['tags'].split(','):
+ tag = datastore.tag_exists_by_name(k.strip())
+ notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None
+
+ if not notification_urls and not is_global_settings_form and not is_group_settings_form:
+ # In the global settings, use only what is typed currently in the text box
+ logger.debug("Test notification - Trying by global system settings notifications")
+ if datastore.data['settings']['application'].get('notification_urls'):
+ notification_urls = datastore.data['settings']['application']['notification_urls']
+
+ if not notification_urls:
+ return 'Error: No Notification URLs set/found'
+
+ for n_url in notification_urls:
+ if len(n_url.strip()):
+ if not apobj.add(n_url):
+ return f'Error: {n_url} is not a valid AppRise URL.'
+
+ try:
+ # use the same as when it is triggered, but then override it with the form test values
+ n_object = {
+ 'watch_url': request.form.get('window_url', "https://changedetection.io"),
+ 'notification_urls': notification_urls
+ }
+
+ # Only use if present, if not set in n_object it should use the default system value
+ if 'notification_format' in request.form and request.form['notification_format'].strip():
+ n_object['notification_format'] = request.form.get('notification_format', '').strip()
+
+ 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())
+ sent_obj = process_notification(n_object, datastore)
+
+ except Exception as e:
+ e_str = str(e)
+ # Remove this text which is not important and floods the container
+ e_str = e_str.replace(
+ "DEBUG - .CustomNotifyPluginWrapper'>",
+ '')
+
+ return make_response(e_str, 400)
+
+ return 'OK - Sent test notifications'
+
+ return notification_blueprint
\ No newline at end of file
diff --git a/changedetectionio/blueprint/ui/templates/clear_all_history.html b/changedetectionio/blueprint/ui/templates/clear_all_history.html
new file mode 100644
index 0000000000000000000000000000000000000000..bcb57190f132d7dab673e7c882e6a88cf16afafc
--- /dev/null
+++ b/changedetectionio/blueprint/ui/templates/clear_all_history.html
@@ -0,0 +1,49 @@
+{% extends 'base.html' %} {% block content %}
+
+
+{% endblock %}
diff --git a/changedetectionio/blueprint/ui/views.py b/changedetectionio/blueprint/ui/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..7954a19710e5c00911f406f9ef2ec8ac7fb84e2c
--- /dev/null
+++ b/changedetectionio/blueprint/ui/views.py
@@ -0,0 +1,221 @@
+from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory, abort
+from flask_login import current_user
+import os
+import time
+from copy import deepcopy
+
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.auth_decorator import login_optionally_required
+from changedetectionio import html_tools
+from changedetectionio import worker_handler
+
+def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData, watch_check_update):
+ views_blueprint = Blueprint('ui_views', __name__, template_folder="../ui/templates")
+
+ @views_blueprint.route("/preview/", methods=['GET'])
+ @login_optionally_required
+ def preview_page(uuid):
+ content = []
+ versions = []
+ timestamp = None
+
+ # More for testing, possible to return the first/only
+ if uuid == 'first':
+ uuid = list(datastore.data['watching'].keys()).pop()
+
+ try:
+ watch = datastore.data['watching'][uuid]
+ except KeyError:
+ flash("No history found for the specified link, bad link?", "error")
+ return redirect(url_for('watchlist.index'))
+
+ system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
+ extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
+
+ is_html_webdriver = False
+ if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
+ is_html_webdriver = True
+ triggered_line_numbers = []
+ if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()):
+ flash("Preview unavailable - No fetch/check completed or triggers not reached", "error")
+ else:
+ # So prepare the latest preview or not
+ preferred_version = request.args.get('version')
+ versions = list(watch.history.keys())
+ timestamp = versions[-1]
+ if preferred_version and preferred_version in versions:
+ timestamp = preferred_version
+
+ try:
+ versions = list(watch.history.keys())
+ content = watch.get_history_snapshot(timestamp)
+
+ triggered_line_numbers = html_tools.strip_ignore_text(content=content,
+ wordlist=watch['trigger_text'],
+ mode='line numbers'
+ )
+
+ except Exception as e:
+ content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''})
+
+ output = render_template("preview.html",
+ content=content,
+ current_version=timestamp,
+ history_n=watch.history_n,
+ extra_stylesheets=extra_stylesheets,
+ extra_title=f" - Diff - {watch.label} @ {timestamp}",
+ triggered_line_numbers=triggered_line_numbers,
+ current_diff_url=watch['url'],
+ screenshot=watch.get_screenshot(),
+ watch=watch,
+ uuid=uuid,
+ is_html_webdriver=is_html_webdriver,
+ last_error=watch['last_error'],
+ last_error_text=watch.get_error_text(),
+ last_error_screenshot=watch.get_error_snapshot(),
+ versions=versions
+ )
+
+ return output
+
+ @views_blueprint.route("/diff/", methods=['GET', 'POST'])
+ @login_optionally_required
+ def diff_history_page(uuid):
+ from changedetectionio import forms
+
+ # More for testing, possible to return the first/only
+ if uuid == 'first':
+ uuid = list(datastore.data['watching'].keys()).pop()
+
+ extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
+ try:
+ watch = datastore.data['watching'][uuid]
+ except KeyError:
+ flash("No history found for the specified link, bad link?", "error")
+ return redirect(url_for('watchlist.index'))
+
+ # For submission of requesting an extract
+ extract_form = forms.extractDataForm(request.form)
+ if request.method == 'POST':
+ if not extract_form.validate():
+ flash("An error occurred, please see below.", "error")
+
+ else:
+ extract_regex = request.form.get('extract_regex').strip()
+ output = watch.extract_regex_from_all_history(extract_regex)
+ if output:
+ watch_dir = os.path.join(datastore.datastore_path, uuid)
+ response = make_response(send_from_directory(directory=watch_dir, path=output, as_attachment=True))
+ response.headers['Content-type'] = 'text/csv'
+ response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
+ response.headers['Pragma'] = 'no-cache'
+ response.headers['Expires'] = 0
+ return response
+
+ flash('Nothing matches that RegEx', 'error')
+ redirect(url_for('ui_views.diff_history_page', uuid=uuid)+'#extract')
+
+ history = watch.history
+ dates = list(history.keys())
+
+ if len(dates) < 2:
+ flash("Not enough saved change detection snapshots to produce a report.", "error")
+ return redirect(url_for('watchlist.index'))
+
+ # Save the current newest history as the most recently viewed
+ datastore.set_last_viewed(uuid, time.time())
+
+ # Read as binary and force decode as UTF-8
+ # Windows may fail decode in python if we just use 'r' mode (chardet decode exception)
+ from_version = request.args.get('from_version')
+ from_version_index = -2 # second newest
+ if from_version and from_version in dates:
+ from_version_index = dates.index(from_version)
+ else:
+ from_version = dates[from_version_index]
+
+ try:
+ from_version_file_contents = watch.get_history_snapshot(dates[from_version_index])
+ except Exception as e:
+ from_version_file_contents = f"Unable to read to-version at index {dates[from_version_index]}.\n"
+
+ to_version = request.args.get('to_version')
+ to_version_index = -1
+ if to_version and to_version in dates:
+ to_version_index = dates.index(to_version)
+ else:
+ to_version = dates[to_version_index]
+
+ try:
+ to_version_file_contents = watch.get_history_snapshot(dates[to_version_index])
+ except Exception as e:
+ to_version_file_contents = "Unable to read to-version at index{}.\n".format(dates[to_version_index])
+
+ screenshot_url = watch.get_screenshot()
+
+ system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
+
+ is_html_webdriver = False
+ if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
+ is_html_webdriver = True
+
+ password_enabled_and_share_is_off = False
+ if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False):
+ password_enabled_and_share_is_off = not datastore.data['settings']['application'].get('shared_diff_access')
+
+ output = render_template("diff.html",
+ current_diff_url=watch['url'],
+ from_version=str(from_version),
+ to_version=str(to_version),
+ extra_stylesheets=extra_stylesheets,
+ extra_title=f" - Diff - {watch.label}",
+ extract_form=extract_form,
+ is_html_webdriver=is_html_webdriver,
+ last_error=watch['last_error'],
+ last_error_screenshot=watch.get_error_snapshot(),
+ last_error_text=watch.get_error_text(),
+ left_sticky=True,
+ newest=to_version_file_contents,
+ newest_version_timestamp=dates[-1],
+ password_enabled_and_share_is_off=password_enabled_and_share_is_off,
+ from_version_file_contents=from_version_file_contents,
+ to_version_file_contents=to_version_file_contents,
+ screenshot=screenshot_url,
+ uuid=uuid,
+ versions=dates, # All except current/last
+ watch_a=watch
+ )
+
+ return output
+
+ @views_blueprint.route("/form/add/quickwatch", methods=['POST'])
+ @login_optionally_required
+ def form_quick_watch_add():
+ from changedetectionio import forms
+ form = forms.quickWatchForm(request.form)
+
+ if not form.validate():
+ for widget, l in form.errors.items():
+ flash(','.join(l), 'error')
+ return redirect(url_for('watchlist.index'))
+
+ url = request.form.get('url').strip()
+ if datastore.url_exists(url):
+ flash(f'Warning, URL {url} already exists', "notice")
+
+ add_paused = request.form.get('edit_and_watch_submit_button') != None
+ processor = request.form.get('processor', 'text_json_diff')
+ new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor})
+
+ if new_uuid:
+ if add_paused:
+ flash('Watch added in Paused state, saving will unpause.')
+ return redirect(url_for('ui.ui_edit.edit_page', uuid=new_uuid, unpause_on_save=1, tag=request.args.get('tag')))
+ else:
+ # Straight into the queue.
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
+ flash("Watch added.")
+
+ return redirect(url_for('watchlist.index', tag=request.args.get('tag','')))
+
+ return views_blueprint
\ No newline at end of file
diff --git a/changedetectionio/blueprint/watchlist/__init__.py b/changedetectionio/blueprint/watchlist/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..8cd5423afefac8cc8d65c06edb34ff0863885cdf
--- /dev/null
+++ b/changedetectionio/blueprint/watchlist/__init__.py
@@ -0,0 +1,112 @@
+import os
+import time
+
+from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash, session
+from flask_login import current_user
+from flask_paginate import Pagination, get_page_parameter
+
+from changedetectionio import forms
+from changedetectionio.store import ChangeDetectionStore
+from changedetectionio.auth_decorator import login_optionally_required
+
+def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
+ watchlist_blueprint = Blueprint('watchlist', __name__, template_folder="templates")
+
+ @watchlist_blueprint.route("/", methods=['GET'])
+ @login_optionally_required
+ def index():
+ active_tag_req = request.args.get('tag', '').lower().strip()
+ active_tag_uuid = active_tag = None
+
+ # Be sure limit_tag is a uuid
+ if active_tag_req:
+ for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
+ if active_tag_req == tag.get('title', '').lower().strip() or active_tag_req == uuid:
+ active_tag = tag
+ active_tag_uuid = uuid
+ break
+
+ # Redirect for the old rss path which used the /?rss=true
+ if request.args.get('rss'):
+ return redirect(url_for('rss.feed', tag=active_tag_uuid))
+
+ op = request.args.get('op')
+ if op:
+ uuid = request.args.get('uuid')
+ if op == 'pause':
+ datastore.data['watching'][uuid].toggle_pause()
+ elif op == 'mute':
+ datastore.data['watching'][uuid].toggle_mute()
+
+ datastore.needs_write = True
+ return redirect(url_for('watchlist.index', tag = active_tag_uuid))
+
+ # Sort by last_changed and add the uuid which is usually the key..
+ sorted_watches = []
+ with_errors = request.args.get('with_errors') == "1"
+ errored_count = 0
+ search_q = request.args.get('q').strip().lower() if request.args.get('q') else False
+ for uuid, watch in datastore.data['watching'].items():
+ if with_errors and not watch.get('last_error'):
+ continue
+
+ if active_tag_uuid and not active_tag_uuid in watch['tags']:
+ continue
+ if watch.get('last_error'):
+ errored_count += 1
+
+ if search_q:
+ if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
+ sorted_watches.append(watch)
+ elif watch.get('last_error') and search_q in watch.get('last_error').lower():
+ sorted_watches.append(watch)
+ else:
+ sorted_watches.append(watch)
+
+ form = forms.quickWatchForm(request.form)
+ page = request.args.get(get_page_parameter(), type=int, default=1)
+ total_count = len(sorted_watches)
+
+ pagination = Pagination(page=page,
+ total=total_count,
+ per_page=datastore.data['settings']['application'].get('pager_size', 50), css_framework="semantic")
+
+ sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
+
+ output = render_template(
+ "watch-overview.html",
+ active_tag=active_tag,
+ active_tag_uuid=active_tag_uuid,
+ app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
+ datastore=datastore,
+ errored_count=errored_count,
+ form=form,
+ guid=datastore.data['app_guid'],
+ has_proxies=datastore.proxy_list,
+ has_unviewed=datastore.has_unviewed,
+ hosted_sticky=os.getenv("SALTED_PASS", False) == False,
+ now_time_server=round(time.time()),
+ pagination=pagination,
+ queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
+ search_q=request.args.get('q', '').strip(),
+ sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
+ sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
+ system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),
+ tags=sorted_tags,
+ watches=sorted_watches
+ )
+
+ if session.get('share-link'):
+ del (session['share-link'])
+
+ resp = make_response(output)
+
+ # The template can run on cookie or url query info
+ if request.args.get('sort'):
+ resp.set_cookie('sort', request.args.get('sort'))
+ if request.args.get('order'):
+ resp.set_cookie('order', request.args.get('order'))
+
+ return resp
+
+ return watchlist_blueprint
\ No newline at end of file
diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview.html b/changedetectionio/blueprint/watchlist/templates/watch-overview.html
new file mode 100644
index 0000000000000000000000000000000000000000..4b7eb2f99907346ec5e5511acc632b8b95cf4431
--- /dev/null
+++ b/changedetectionio/blueprint/watchlist/templates/watch-overview.html
@@ -0,0 +1,227 @@
+{%- extends 'base.html' -%}
+{%- block content -%}
+{%- from '_helpers.html' import render_simple_field, render_field, render_nolabel_field, sort_by_title -%}
+
+
+
+
+
+
+
+
+
+
+ Add a new change detection watch
+
+
+ {{ render_nolabel_field(form.url, placeholder="https://...", required=true) }}
+ {{ render_nolabel_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder="watch label / tag") }}
+ {{ render_nolabel_field(form.watch_submit_button, title="Watch this URL!" ) }}
+ {{ render_nolabel_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }}
+
+
+ {{ render_simple_field(form.processor) }}
+
+
+
+ Tip: You can also add 'shared' watches. More info
+
+
+
+
+
+
+ Pause
+ UnPause
+ Mute
+ UnMute
+ Recheck
+ Tag
+ Mark viewed
+ Use default notification
+ Clear errors
+ Clear/reset history
+ Delete
+
+ {%- if watches|length >= pagination.per_page -%}
+ {{ pagination.info }}
+ {%- endif -%}
+ {%- if search_q -%}Searching "{{search_q}} "
{%- endif -%}
+
+
All
+
+
+ {%- for uuid, tag in tags -%}
+ {%- if tag != "" -%}
+
{{ tag.title }}
+ {%- endif -%}
+ {%- endfor -%}
+
+
+ {%- set sort_order = sort_order or 'asc' -%}
+ {%- set sort_attribute = sort_attribute or 'last_changed' -%}
+ {%- set pagination_page = request.args.get('page', 0) -%}
+ {%- set cols_required = 6 -%}
+ {%- set any_has_restock_price_processor = datastore.any_watches_have_processor_by_name("restock_diff") -%}
+ {%- if any_has_restock_price_processor -%}
+ {%- set cols_required = cols_required + 1 -%}
+ {%- endif -%}
+
+
+
+
+
+
+ {%- set link_order = "desc" if sort_order == 'asc' else "asc" -%}
+ {%- set arrow_span = "" -%}
+ #
+
+ Website
+ {%- if any_has_restock_price_processor -%}
+ Restock & Price
+ {%- endif -%}
+ Last Checked
+ Last Changed
+
+
+
+
+ {%- if not watches|length -%}
+
+ No website watches configured, please add a URL in the box above, or import a list .
+
+ {%- endif -%}
+ {%- for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) -%}
+ {%- set checking_now = is_checking_now(watch) -%}
+ {%- set history_n = watch.history_n -%}
+ {# Mirror in changedetectionio/static/js/realtime.js for the frontend #}
+ {%- set row_classes = [
+ loop.cycle('pure-table-odd', 'pure-table-even'),
+ 'processor-' ~ watch['processor'],
+ 'has-error' if watch.compile_error_texts()|length > 2 else '',
+ 'paused' if watch.paused is defined and watch.paused != False else '',
+ 'unviewed' if watch.has_unviewed else '',
+ 'has-restock-info' if watch.has_restock_info else 'no-restock-info',
+ 'in-stock' if watch.has_restock_info and watch['restock']['in_stock'] else '',
+ 'not-in-stock' if watch.has_restock_info and not watch['restock']['in_stock'] else '',
+ 'queued' if watch.uuid in queued_uuids else '',
+ 'checking-now' if checking_now else '',
+ 'notification_muted' if watch.notification_muted else '',
+ 'single-history' if history_n == 1 else '',
+ 'multiple-history' if history_n >= 2 else ''
+ ] -%}
+
+ {{ loop.index+pagination.skip }}
+
+
+
+
+
+
+ {{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}
+
+
+
+ {%- if watch.get_fetch_backend == "html_webdriver"
+ or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' )
+ or "extra_browser_" in watch.get_fetch_backend
+ -%}
+
+ {%- endif -%}
+
+ {%- if watch.is_pdf -%} {%- endif -%}
+ {%- if watch.has_browser_steps -%} {%- endif -%}
+
+ {{ watch.compile_error_texts(has_proxies=datastore.proxy_list)|safe }}
+
+ {%- if watch['processor'] == 'text_json_diff' -%}
+ {%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%}
+ Switch to Restock & Price watch mode?
Yes No
+ {%- endif -%}
+ {%- endif -%}
+ {%- if watch['processor'] == 'restock_diff' -%}
+ Price
+ {%- endif -%}
+ {%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
+ {{ watch_tag.title }}
+ {%- endfor -%}
+
+{%- if any_has_restock_price_processor -%}
+
+ {%- if watch['processor'] == 'restock_diff' -%}
+ {%- if watch.has_restock_info -%}
+
+
+ {%- if watch['restock']['in_stock']-%} In stock {%- else-%} Not in stock {%- endif -%}
+
+ {%- endif -%}
+
+ {%- if watch.get('restock') and watch['restock']['price'] != None -%}
+ {%- if watch['restock']['price'] != None -%}
+
+ {{ watch['restock']['price']|format_number_locale }} {{ watch['restock']['currency'] }}
+
+ {%- endif -%}
+ {%- elif not watch.has_restock_info -%}
+ No information
+ {%- endif -%}
+ {%- endif -%}
+
+{%- endif -%}
+ {#last_checked becomes fetch-start-time#}
+
+
+ Checking now
+
+ {{watch|format_last_checked_time|safe}}
+
+ {%- if watch.history_n >=2 and watch.last_changed >0 -%}
+ {{watch.last_changed|format_timestamp_timeago}}
+ {%- else -%}
+ Not yet
+ {%- endif -%}
+
+
+ {%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%}
+ Queued
+ Recheck
+ Edit
+ History
+ Preview
+
+
+ {%- endfor -%}
+
+
+
+ {{ pagination.links }}
+
+
+
+{%- endblock -%}
\ No newline at end of file
diff --git a/changedetectionio/conditions/__init__.py b/changedetectionio/conditions/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c09e526d35b5e1372dab2a9bbb706e27a65e9a7f
--- /dev/null
+++ b/changedetectionio/conditions/__init__.py
@@ -0,0 +1,170 @@
+from flask import Blueprint
+
+from json_logic.builtins import BUILTINS
+
+from .exceptions import EmptyConditionRuleRowNotUsable
+from .pluggy_interface import plugin_manager # Import the pluggy plugin manager
+from . import default_plugin
+from loguru import logger
+# List of all supported JSON Logic operators
+operator_choices = [
+ (None, "Choose one - Operator"),
+ (">", "Greater Than"),
+ ("<", "Less Than"),
+ (">=", "Greater Than or Equal To"),
+ ("<=", "Less Than or Equal To"),
+ ("==", "Equals"),
+ ("!=", "Not Equals"),
+ ("in", "Contains"),
+ ("!in", "Does Not Contain"),
+]
+
+# Fields available in the rules
+field_choices = [
+ (None, "Choose one - Field"),
+]
+
+# The data we will feed the JSON Rules to see if it passes the test/conditions or not
+EXECUTE_DATA = {}
+
+
+# Define the extended operations dictionary
+CUSTOM_OPERATIONS = {
+ **BUILTINS, # Include all standard operators
+}
+
+def filter_complete_rules(ruleset):
+ rules = [
+ rule for rule in ruleset
+ if all(value not in ("", False, "None", None) for value in [rule["operator"], rule["field"], rule["value"]])
+ ]
+ return rules
+
+def convert_to_jsonlogic(logic_operator: str, rule_dict: list):
+ """
+ Convert a structured rule dict into a JSON Logic rule.
+
+ :param rule_dict: Dictionary containing conditions.
+ :return: JSON Logic rule as a dictionary.
+ """
+
+
+ json_logic_conditions = []
+
+ for condition in rule_dict:
+ operator = condition["operator"]
+ field = condition["field"]
+ value = condition["value"]
+
+ if not operator or operator == 'None' or not value or not field:
+ raise EmptyConditionRuleRowNotUsable()
+
+ # Convert value to int/float if possible
+ try:
+ if isinstance(value, str) and "." in value and str != "None":
+ value = float(value)
+ else:
+ value = int(value)
+ except (ValueError, TypeError):
+ pass # Keep as a string if conversion fails
+
+ # Handle different JSON Logic operators properly
+ if operator == "in":
+ json_logic_conditions.append({"in": [value, {"var": field}]}) # value first
+ elif operator in ("!", "!!", "-"):
+ json_logic_conditions.append({operator: [{"var": field}]}) # Unary operators
+ elif operator in ("min", "max", "cat"):
+ json_logic_conditions.append({operator: value}) # Multi-argument operators
+ else:
+ json_logic_conditions.append({operator: [{"var": field}, value]}) # Standard binary operators
+
+ return {logic_operator: json_logic_conditions} if len(json_logic_conditions) > 1 else json_logic_conditions[0]
+
+
+def execute_ruleset_against_all_plugins(current_watch_uuid: str, application_datastruct, ephemeral_data={} ):
+ """
+ Build our data and options by calling our plugins then pass it to jsonlogic and see if the conditions pass
+
+ :param ruleset: JSON Logic rule dictionary.
+ :param extracted_data: Dictionary containing the facts. <-- maybe the app struct+uuid
+ :return: Dictionary of plugin results.
+ """
+ from json_logic import jsonLogic
+
+ EXECUTE_DATA = {}
+ result = True
+
+ watch = application_datastruct['watching'].get(current_watch_uuid)
+
+ if watch and watch.get("conditions"):
+ logic_operator = "and" if watch.get("conditions_match_logic", "ALL") == "ALL" else "or"
+ complete_rules = filter_complete_rules(watch['conditions'])
+ if complete_rules:
+ # Give all plugins a chance to update the data dict again (that we will test the conditions against)
+ for plugin in plugin_manager.get_plugins():
+ try:
+ import concurrent.futures
+ import time
+
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(
+ plugin.add_data,
+ current_watch_uuid=current_watch_uuid,
+ application_datastruct=application_datastruct,
+ ephemeral_data=ephemeral_data
+ )
+ logger.debug(f"Trying plugin {plugin}....")
+
+ # Set a timeout of 10 seconds
+ try:
+ new_execute_data = future.result(timeout=10)
+ if new_execute_data and isinstance(new_execute_data, dict):
+ EXECUTE_DATA.update(new_execute_data)
+
+ except concurrent.futures.TimeoutError:
+ # The plugin took too long, abort processing for this watch
+ raise Exception(f"Plugin {plugin.__class__.__name__} took more than 10 seconds to run.")
+ except Exception as e:
+ # Log the error but continue with the next plugin
+ import logging
+ logging.error(f"Error executing plugin {plugin.__class__.__name__}: {str(e)}")
+ continue
+
+ # Create the ruleset
+ ruleset = convert_to_jsonlogic(logic_operator=logic_operator, rule_dict=complete_rules)
+
+ # Pass the custom operations dictionary to jsonLogic
+ if not jsonLogic(logic=ruleset, data=EXECUTE_DATA, operations=CUSTOM_OPERATIONS):
+ result = False
+
+ return {'executed_data': EXECUTE_DATA, 'result': result}
+
+# Load plugins dynamically
+for plugin in plugin_manager.get_plugins():
+ new_ops = plugin.register_operators()
+ if isinstance(new_ops, dict):
+ CUSTOM_OPERATIONS.update(new_ops)
+
+ new_operator_choices = plugin.register_operator_choices()
+ if isinstance(new_operator_choices, list):
+ operator_choices.extend(new_operator_choices)
+
+ new_field_choices = plugin.register_field_choices()
+ if isinstance(new_field_choices, list):
+ field_choices.extend(new_field_choices)
+
+def collect_ui_edit_stats_extras(watch):
+ """Collect and combine HTML content from all plugins that implement ui_edit_stats_extras"""
+ extras_content = []
+
+ for plugin in plugin_manager.get_plugins():
+ try:
+ content = plugin.ui_edit_stats_extras(watch=watch)
+ if content:
+ extras_content.append(content)
+ except Exception as e:
+ # Skip plugins that don't implement the hook or have errors
+ pass
+
+ return "\n".join(extras_content) if extras_content else ""
+
diff --git a/changedetectionio/conditions/blueprint.py b/changedetectionio/conditions/blueprint.py
new file mode 100644
index 0000000000000000000000000000000000000000..9d8766e94c138e4dffa982bc652a6e2f5da422dd
--- /dev/null
+++ b/changedetectionio/conditions/blueprint.py
@@ -0,0 +1,81 @@
+# Flask Blueprint Definition
+import json
+
+from flask import Blueprint
+
+from changedetectionio.conditions import execute_ruleset_against_all_plugins
+
+
+def construct_blueprint(datastore):
+ from changedetectionio.flask_app import login_optionally_required
+
+ conditions_blueprint = Blueprint('conditions', __name__, template_folder="templates")
+
+ @conditions_blueprint.route("//verify-condition-single-rule", methods=['POST'])
+ @login_optionally_required
+ def verify_condition_single_rule(watch_uuid):
+ """Verify a single condition rule against the current snapshot"""
+ from changedetectionio.processors.text_json_diff import prepare_filter_prevew
+ from flask import request, jsonify
+ from copy import deepcopy
+
+ ephemeral_data = {}
+
+ # Get the watch data
+ watch = datastore.data['watching'].get(watch_uuid)
+ if not watch:
+ return jsonify({'status': 'error', 'message': 'Watch not found'}), 404
+
+ # First use prepare_filter_prevew to process the form data
+ # This will return text_after_filter which is after all current form settings are applied
+ # Create ephemeral data with the text from the current snapshot
+
+ try:
+ # Call prepare_filter_prevew to get a processed version of the content with current form settings
+ # We'll ignore the returned response and just use the datastore which is modified by the function
+
+ # this should apply all filters etc so then we can run the CONDITIONS against the final output text
+ result = prepare_filter_prevew(datastore=datastore,
+ form_data=request.form,
+ watch_uuid=watch_uuid)
+
+ ephemeral_data['text'] = result.get('after_filter', '')
+ # Create a temporary watch data structure with this single rule
+ tmp_watch_data = deepcopy(datastore.data['watching'].get(watch_uuid))
+
+ # Override the conditions in the temporary watch
+ rule_json = request.args.get("rule")
+ rule = json.loads(rule_json) if rule_json else None
+
+ # Should be key/value of field, operator, value
+ tmp_watch_data['conditions'] = [rule]
+ tmp_watch_data['conditions_match_logic'] = "ALL" # Single rule, so use ALL
+
+ # Create a temporary application data structure for the rule check
+ temp_app_data = {
+ 'watching': {
+ watch_uuid: tmp_watch_data
+ }
+ }
+
+ # Execute the rule against the current snapshot with form data
+ result = execute_ruleset_against_all_plugins(
+ current_watch_uuid=watch_uuid,
+ application_datastruct=temp_app_data,
+ ephemeral_data=ephemeral_data
+ )
+
+ return jsonify({
+ 'status': 'success',
+ 'result': result.get('result'),
+ 'data': result.get('executed_data'),
+ 'message': 'Condition passes' if result else 'Condition does not pass'
+ })
+
+ except Exception as e:
+ return jsonify({
+ 'status': 'error',
+ 'message': f'Error verifying condition: {str(e)}'
+ }), 500
+
+ return conditions_blueprint
\ No newline at end of file
diff --git a/changedetectionio/conditions/default_plugin.py b/changedetectionio/conditions/default_plugin.py
new file mode 100644
index 0000000000000000000000000000000000000000..ea714adac07fea028227d95bff850f5ae7cb95eb
--- /dev/null
+++ b/changedetectionio/conditions/default_plugin.py
@@ -0,0 +1,78 @@
+import re
+
+import pluggy
+from price_parser import Price
+from loguru import logger
+
+hookimpl = pluggy.HookimplMarker("changedetectionio_conditions")
+
+
+@hookimpl
+def register_operators():
+ def starts_with(_, text, prefix):
+ return text.lower().strip().startswith(str(prefix).strip().lower())
+
+ def ends_with(_, text, suffix):
+ return text.lower().strip().endswith(str(suffix).strip().lower())
+
+ def length_min(_, text, strlen):
+ return len(text) >= int(strlen)
+
+ def length_max(_, text, strlen):
+ return len(text) <= int(strlen)
+
+ # ✅ Custom function for case-insensitive regex matching
+ def contains_regex(_, text, pattern):
+ """Returns True if `text` contains `pattern` (case-insensitive regex match)."""
+ return bool(re.search(pattern, str(text), re.IGNORECASE))
+
+ # ✅ Custom function for NOT matching case-insensitive regex
+ def not_contains_regex(_, text, pattern):
+ """Returns True if `text` does NOT contain `pattern` (case-insensitive regex match)."""
+ return not bool(re.search(pattern, str(text), re.IGNORECASE))
+
+ return {
+ "!contains_regex": not_contains_regex,
+ "contains_regex": contains_regex,
+ "ends_with": ends_with,
+ "length_max": length_max,
+ "length_min": length_min,
+ "starts_with": starts_with,
+ }
+
+@hookimpl
+def register_operator_choices():
+ return [
+ ("starts_with", "Text Starts With"),
+ ("ends_with", "Text Ends With"),
+ ("length_min", "Length minimum"),
+ ("length_max", "Length maximum"),
+ ("contains_regex", "Text Matches Regex"),
+ ("!contains_regex", "Text Does NOT Match Regex"),
+ ]
+
+@hookimpl
+def register_field_choices():
+ return [
+ ("extracted_number", "Extracted number after 'Filters & Triggers'"),
+# ("meta_description", "Meta Description"),
+# ("meta_keywords", "Meta Keywords"),
+ ("page_filtered_text", "Page text after 'Filters & Triggers'"),
+ #("page_title", "Page "), # actual page title
+ ]
+
+@hookimpl
+def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
+
+ res = {}
+ if 'text' in ephemeral_data:
+ res['page_filtered_text'] = ephemeral_data['text']
+
+ # Better to not wrap this in try/except so that the UI can see any errors
+ price = Price.fromstring(ephemeral_data.get('text'))
+ if price and price.amount != None:
+ # This is slightly misleading, it's extracting a PRICE not a Number..
+ res['extracted_number'] = float(price.amount)
+ logger.debug(f"Extracted number result: '{price}' - returning float({res['extracted_number']})")
+
+ return res
diff --git a/changedetectionio/conditions/exceptions.py b/changedetectionio/conditions/exceptions.py
new file mode 100644
index 0000000000000000000000000000000000000000..b9a38d987f95b15fc610dc5d4c71bdd4c47cac23
--- /dev/null
+++ b/changedetectionio/conditions/exceptions.py
@@ -0,0 +1,6 @@
+class EmptyConditionRuleRowNotUsable(Exception):
+ def __init__(self):
+ super().__init__("One of the 'conditions' rulesets is incomplete, cannot run.")
+
+ def __str__(self):
+ return self.args[0]
\ No newline at end of file
diff --git a/changedetectionio/conditions/form.py b/changedetectionio/conditions/form.py
new file mode 100644
index 0000000000000000000000000000000000000000..8effbdefd996df1f80226eed15b9cae5814752be
--- /dev/null
+++ b/changedetectionio/conditions/form.py
@@ -0,0 +1,44 @@
+# Condition Rule Form (for each rule row)
+from wtforms import Form, SelectField, StringField, validators
+from wtforms import validators
+
+class ConditionFormRow(Form):
+
+ # ✅ Ensure Plugins Are Loaded BEFORE Importing Choices
+ from changedetectionio.conditions import plugin_manager
+ from changedetectionio.conditions import operator_choices, field_choices
+ field = SelectField(
+ "Field",
+ choices=field_choices,
+ validators=[validators.Optional()]
+ )
+
+ operator = SelectField(
+ "Operator",
+ choices=operator_choices,
+ validators=[validators.Optional()]
+ )
+
+ value = StringField("Value", validators=[validators.Optional()], render_kw={"placeholder": "A value"})
+
+ def validate(self, extra_validators=None):
+ # First, run the default validators
+ if not super().validate(extra_validators):
+ return False
+
+ # Custom validation logic
+ # If any of the operator/field/value is set, then they must be all set
+ if any(value not in ("", False, "None", None) for value in [self.operator.data, self.field.data, self.value.data]):
+ if not self.operator.data or self.operator.data == 'None':
+ self.operator.errors.append("Operator is required.")
+ return False
+
+ if not self.field.data or self.field.data == 'None':
+ self.field.errors.append("Field is required.")
+ return False
+
+ if not self.value.data:
+ self.value.errors.append("Value is required.")
+ return False
+
+ return True # Only return True if all conditions pass
\ No newline at end of file
diff --git a/changedetectionio/conditions/pluggy_interface.py b/changedetectionio/conditions/pluggy_interface.py
new file mode 100644
index 0000000000000000000000000000000000000000..f67b9f51781e6b8bd0b197ef513278fceaf0a935
--- /dev/null
+++ b/changedetectionio/conditions/pluggy_interface.py
@@ -0,0 +1,74 @@
+import pluggy
+import os
+import importlib
+import sys
+from . import default_plugin
+
+# ✅ Ensure that the namespace in HookspecMarker matches PluginManager
+PLUGIN_NAMESPACE = "changedetectionio_conditions"
+
+hookspec = pluggy.HookspecMarker(PLUGIN_NAMESPACE)
+hookimpl = pluggy.HookimplMarker(PLUGIN_NAMESPACE)
+
+
+class ConditionsSpec:
+ """Hook specifications for extending JSON Logic conditions."""
+
+ @hookspec
+ def register_operators():
+ """Return a dictionary of new JSON Logic operators."""
+ pass
+
+ @hookspec
+ def register_operator_choices():
+ """Return a list of new operator choices."""
+ pass
+
+ @hookspec
+ def register_field_choices():
+ """Return a list of new field choices."""
+ pass
+
+ @hookspec
+ def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
+ """Add to the datadict"""
+ pass
+
+ @hookspec
+ def ui_edit_stats_extras(watch):
+ """Return HTML content to add to the stats tab in the edit view"""
+ pass
+
+# ✅ Set up Pluggy Plugin Manager
+plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
+
+# ✅ Register hookspecs (Ensures they are detected)
+plugin_manager.add_hookspecs(ConditionsSpec)
+
+# ✅ Register built-in plugins manually
+plugin_manager.register(default_plugin, "default_plugin")
+
+# ✅ Load plugins from the plugins directory
+def load_plugins_from_directory():
+ plugins_dir = os.path.join(os.path.dirname(__file__), 'plugins')
+ if not os.path.exists(plugins_dir):
+ return
+
+ # Get all Python files (excluding __init__.py)
+ for filename in os.listdir(plugins_dir):
+ if filename.endswith(".py") and filename != "__init__.py":
+ module_name = filename[:-3] # Remove .py extension
+ module_path = f"changedetectionio.conditions.plugins.{module_name}"
+
+ try:
+ module = importlib.import_module(module_path)
+ # Register the plugin with pluggy
+ plugin_manager.register(module, module_name)
+ except (ImportError, AttributeError) as e:
+ print(f"Error loading plugin {module_name}: {e}")
+
+# Load plugins from the plugins directory
+load_plugins_from_directory()
+
+# ✅ Discover installed plugins from external packages (if any)
+plugin_manager.load_setuptools_entrypoints(PLUGIN_NAMESPACE)
diff --git a/changedetectionio/conditions/plugins/__init__.py b/changedetectionio/conditions/plugins/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e642ff36443ba283046d3dd00db8400b9880846
--- /dev/null
+++ b/changedetectionio/conditions/plugins/__init__.py
@@ -0,0 +1 @@
+# Import plugins package to make them discoverable
\ No newline at end of file
diff --git a/changedetectionio/conditions/plugins/levenshtein_plugin.py b/changedetectionio/conditions/plugins/levenshtein_plugin.py
new file mode 100644
index 0000000000000000000000000000000000000000..dbd32391d2d83aabccc140d23dc1a90ce26b8393
--- /dev/null
+++ b/changedetectionio/conditions/plugins/levenshtein_plugin.py
@@ -0,0 +1,107 @@
+import pluggy
+from loguru import logger
+
+# Support both plugin systems
+conditions_hookimpl = pluggy.HookimplMarker("changedetectionio_conditions")
+global_hookimpl = pluggy.HookimplMarker("changedetectionio")
+
+def levenshtein_ratio_recent_history(watch, incoming_text=None):
+ try:
+ from Levenshtein import ratio, distance
+ k = list(watch.history.keys())
+ a = None
+ b = None
+
+ # When called from ui_edit_stats_extras, we don't have incoming_text
+ if incoming_text is None:
+ a = watch.get_history_snapshot(timestamp=k[-1]) # Latest snapshot
+ b = watch.get_history_snapshot(timestamp=k[-2]) # Previous snapshot
+
+ # Needs atleast one snapshot
+ elif len(k) >= 1: # Should be atleast one snapshot to compare against
+ a = watch.get_history_snapshot(timestamp=k[-1]) # Latest saved snapshot
+ b = incoming_text if incoming_text else k[-2]
+
+ if a and b:
+ distance_value = distance(a, b)
+ ratio_value = ratio(a, b)
+ return {
+ 'distance': distance_value,
+ 'ratio': ratio_value,
+ 'percent_similar': round(ratio_value * 100, 2)
+ }
+ except Exception as e:
+ logger.warning(f"Unable to calc similarity: {str(e)}")
+
+ return ''
+
+@conditions_hookimpl
+def register_operators():
+ pass
+
+@conditions_hookimpl
+def register_operator_choices():
+ pass
+
+
+@conditions_hookimpl
+def register_field_choices():
+ return [
+ ("levenshtein_ratio", "Levenshtein - Text similarity ratio"),
+ ("levenshtein_distance", "Levenshtein - Text change distance"),
+ ]
+
+@conditions_hookimpl
+def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
+ res = {}
+ watch = application_datastruct['watching'].get(current_watch_uuid)
+ # ephemeral_data['text'] will be the current text after filters, they may have edited filters but not saved them yet etc
+
+ if watch and 'text' in ephemeral_data:
+ lev_data = levenshtein_ratio_recent_history(watch, ephemeral_data.get('text',''))
+ if isinstance(lev_data, dict):
+ res['levenshtein_ratio'] = lev_data.get('ratio', 0)
+ res['levenshtein_similarity'] = lev_data.get('percent_similar', 0)
+ res['levenshtein_distance'] = lev_data.get('distance', 0)
+
+ return res
+
+@global_hookimpl
+def ui_edit_stats_extras(watch):
+ """Add Levenshtein stats to the UI using the global plugin system"""
+ """Generate the HTML for Levenshtein stats - shared by both plugin systems"""
+ if len(watch.history.keys()) < 2:
+ return "Not enough history to calculate Levenshtein metrics
"
+
+ try:
+ lev_data = levenshtein_ratio_recent_history(watch)
+ if not lev_data or not isinstance(lev_data, dict):
+ return "Unable to calculate Levenshtein metrics
"
+
+ html = f"""
+
+
Levenshtein Text Similarity Details
+
+
+
+ Raw distance (edits needed)
+ {lev_data['distance']}
+
+
+ Similarity ratio
+ {lev_data['ratio']:.4f}
+
+
+ Percent similar
+ {lev_data['percent_similar']}%
+
+
+
+
Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one into the other.
+
+ """
+ return html
+ except Exception as e:
+ logger.error(f"Error generating Levenshtein UI extras: {str(e)}")
+ return "Error calculating Levenshtein metrics
"
+
diff --git a/changedetectionio/conditions/plugins/wordcount_plugin.py b/changedetectionio/conditions/plugins/wordcount_plugin.py
new file mode 100644
index 0000000000000000000000000000000000000000..a19d33538a00914c9acda063542efd4b0e12893c
--- /dev/null
+++ b/changedetectionio/conditions/plugins/wordcount_plugin.py
@@ -0,0 +1,82 @@
+import pluggy
+from loguru import logger
+
+# Support both plugin systems
+conditions_hookimpl = pluggy.HookimplMarker("changedetectionio_conditions")
+global_hookimpl = pluggy.HookimplMarker("changedetectionio")
+
+def count_words_in_history(watch, incoming_text=None):
+ """Count words in snapshot text"""
+ try:
+ if incoming_text is not None:
+ # When called from add_data with incoming text
+ return len(incoming_text.split())
+ elif watch.history.keys():
+ # When called from UI extras to count latest snapshot
+ latest_key = list(watch.history.keys())[-1]
+ latest_content = watch.get_history_snapshot(latest_key)
+ return len(latest_content.split())
+ return 0
+ except Exception as e:
+ logger.error(f"Error counting words: {str(e)}")
+ return 0
+
+# Implement condition plugin hooks
+@conditions_hookimpl
+def register_operators():
+ # No custom operators needed
+ return {}
+
+@conditions_hookimpl
+def register_operator_choices():
+ # No custom operator choices needed
+ return []
+
+@conditions_hookimpl
+def register_field_choices():
+ # Add a field that will be available in conditions
+ return [
+ ("word_count", "Word count of content"),
+ ]
+
+@conditions_hookimpl
+def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
+ """Add word count data for conditions"""
+ result = {}
+ watch = application_datastruct['watching'].get(current_watch_uuid)
+
+ if watch and 'text' in ephemeral_data:
+ word_count = count_words_in_history(watch, ephemeral_data['text'])
+ result['word_count'] = word_count
+
+ return result
+
+def _generate_stats_html(watch):
+ """Generate the HTML content for the stats tab"""
+ word_count = count_words_in_history(watch)
+
+ html = f"""
+
+
Content Analysis
+
+
+
+ Word count (latest snapshot)
+ {word_count}
+
+
+
+
Word count is a simple measure of content length, calculated by splitting text on whitespace.
+
+ """
+ return html
+
+@conditions_hookimpl
+def ui_edit_stats_extras(watch):
+ """Add word count stats to the UI through conditions plugin system"""
+ return _generate_stats_html(watch)
+
+@global_hookimpl
+def ui_edit_stats_extras(watch):
+ """Add word count stats to the UI using the global plugin system"""
+ return _generate_stats_html(watch)
\ No newline at end of file
diff --git a/changedetectionio/content_fetchers/__init__.py b/changedetectionio/content_fetchers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e9d89ce60dc6a1aa9b7eafa9f20028050f71588e
--- /dev/null
+++ b/changedetectionio/content_fetchers/__init__.py
@@ -0,0 +1,63 @@
+import sys
+from changedetectionio.strtobool import strtobool
+from loguru import logger
+from changedetectionio.content_fetchers.exceptions import BrowserStepsStepException
+import os
+
+# Visual Selector scraper - 'Button' is there because some sites have OUT OF STOCK .
+visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4,header,footer,section,article,aside,details,main,nav,section,summary,button'
+
+SCREENSHOT_MAX_HEIGHT_DEFAULT = 20000
+SCREENSHOT_DEFAULT_QUALITY = 40
+
+# Maximum total height for the final image (When in stitch mode).
+# We limit this to 16000px due to the huge amount of RAM that was being used
+# Example: 16000 × 1400 × 3 = 67,200,000 bytes ≈ 64.1 MB (not including buffers in PIL etc)
+SCREENSHOT_MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
+
+# The size at which we will switch to stitching method, when below this (and
+# MAX_TOTAL_HEIGHT which can be set by a user) we will use the default
+# screenshot method.
+SCREENSHOT_SIZE_STITCH_THRESHOLD = 8000
+
+# available_fetchers() will scan this implementation looking for anything starting with html_
+# this information is used in the form selections
+from changedetectionio.content_fetchers.requests import fetcher as html_requests
+
+
+import importlib.resources
+XPATH_ELEMENT_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8')
+INSTOCK_DATA_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8')
+
+
+def available_fetchers():
+ # See the if statement at the bottom of this file for how we switch between playwright and webdriver
+ import inspect
+ p = []
+ for name, obj in inspect.getmembers(sys.modules[__name__], inspect.isclass):
+ if inspect.isclass(obj):
+ # @todo html_ is maybe better as fetcher_ or something
+ # In this case, make sure to edit the default one in store.py and fetch_site_status.py
+ if name.startswith('html_'):
+ t = tuple([name, obj.fetcher_description])
+ p.append(t)
+
+ return p
+
+
+# Decide which is the 'real' HTML webdriver, this is more a system wide config
+# rather than site-specific.
+use_playwright_as_chrome_fetcher = os.getenv('PLAYWRIGHT_DRIVER_URL', False)
+if use_playwright_as_chrome_fetcher:
+ # @note - For now, browser steps always uses playwright
+ if not strtobool(os.getenv('FAST_PUPPETEER_CHROME_FETCHER', 'False')):
+ logger.debug('Using Playwright library as fetcher')
+ from .playwright import fetcher as html_webdriver
+ else:
+ logger.debug('Using direct Python Puppeteer library as fetcher')
+ from .puppeteer import fetcher as html_webdriver
+
+else:
+ logger.debug("Falling back to selenium as fetcher")
+ from .webdriver_selenium import fetcher as html_webdriver
+
diff --git a/changedetectionio/content_fetchers/base.py b/changedetectionio/content_fetchers/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..1abce26dec7ad91241acbab13f475b44f88caa0f
--- /dev/null
+++ b/changedetectionio/content_fetchers/base.py
@@ -0,0 +1,175 @@
+import os
+from abc import abstractmethod
+from loguru import logger
+
+from changedetectionio.content_fetchers import BrowserStepsStepException
+
+
+def manage_user_agent(headers, current_ua=''):
+ """
+ Basic setting of user-agent
+
+ NOTE!!!!!! The service that does the actual Chrome fetching should handle any anti-robot techniques
+ THERE ARE MANY WAYS THAT IT CAN BE DETECTED AS A ROBOT!!
+ This does not take care of
+ - Scraping of 'navigator' (platform, productSub, vendor, oscpu etc etc) browser object (navigator.appVersion) etc
+ - TCP/IP fingerprint JA3 etc
+ - Graphic rendering fingerprinting
+ - Your IP being obviously in a pool of bad actors
+ - Too many requests
+ - Scraping of SCH-UA browser replies (thanks google!!)
+ - Scraping of ServiceWorker, new window calls etc
+
+ See https://filipvitas.medium.com/how-to-set-user-agent-header-with-puppeteer-js-and-not-fail-28c7a02165da
+ Puppeteer requests https://github.com/dgtlmoon/pyppeteerstealth
+
+ :param page:
+ :param headers:
+ :return:
+ """
+ # Ask it what the user agent is, if its obviously ChromeHeadless, switch it to the default
+ ua_in_custom_headers = headers.get('User-Agent')
+ if ua_in_custom_headers:
+ return ua_in_custom_headers
+
+ if not ua_in_custom_headers and current_ua:
+ current_ua = current_ua.replace('HeadlessChrome', 'Chrome')
+ return current_ua
+
+ return None
+
+
+class Fetcher():
+ browser_connection_is_custom = None
+ browser_connection_url = None
+ browser_steps = None
+ browser_steps_screenshot_path = None
+ content = None
+ error = None
+ fetcher_description = "No description"
+ headers = {}
+ instock_data = None
+ instock_data_js = ""
+ status_code = None
+ webdriver_js_execute_code = None
+ xpath_data = None
+ xpath_element_js = ""
+
+ # Will be needed in the future by the VisualSelector, always get this where possible.
+ screenshot = False
+ system_http_proxy = os.getenv('HTTP_PROXY')
+ system_https_proxy = os.getenv('HTTPS_PROXY')
+
+ # Time ONTOP of the system defined env minimum time
+ render_extract_delay = 0
+
+ @abstractmethod
+ def get_error(self):
+ return self.error
+
+ @abstractmethod
+ async def run(self,
+ url,
+ timeout,
+ request_headers,
+ request_body,
+ request_method,
+ ignore_status_codes=False,
+ current_include_filters=None,
+ is_binary=False,
+ empty_pages_are_a_change=False):
+ # Should set self.error, self.status_code and self.content
+ pass
+
+ @abstractmethod
+ def quit(self, watch=None):
+ return
+
+ @abstractmethod
+ def get_last_status_code(self):
+ return self.status_code
+
+ @abstractmethod
+ def screenshot_step(self, step_n):
+ if self.browser_steps_screenshot_path and not os.path.isdir(self.browser_steps_screenshot_path):
+ logger.debug(f"> Creating data dir {self.browser_steps_screenshot_path}")
+ os.mkdir(self.browser_steps_screenshot_path)
+ return None
+
+ @abstractmethod
+ # Return true/false if this checker is ready to run, in the case it needs todo some special config check etc
+ def is_ready(self):
+ return True
+
+ def get_all_headers(self):
+ """
+ Get all headers but ensure all keys are lowercase
+ :return:
+ """
+ return {k.lower(): v for k, v in self.headers.items()}
+
+ def browser_steps_get_valid_steps(self):
+ if self.browser_steps is not None and len(self.browser_steps):
+ valid_steps = list(filter(
+ lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one'),
+ self.browser_steps))
+
+ # Just incase they selected Goto site by accident with older JS
+ if valid_steps and valid_steps[0]['operation'] == 'Goto site':
+ del(valid_steps[0])
+
+ return valid_steps
+
+ return None
+
+ async def iterate_browser_steps(self, start_url=None):
+ from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface
+ from playwright._impl._errors import TimeoutError, Error
+ from changedetectionio.safe_jinja import render as jinja_render
+ step_n = 0
+
+ if self.browser_steps is not None and len(self.browser_steps):
+ interface = steppable_browser_interface(start_url=start_url)
+ interface.page = self.page
+ valid_steps = self.browser_steps_get_valid_steps()
+
+ for step in valid_steps:
+ step_n += 1
+ logger.debug(f">> Iterating check - browser Step n {step_n} - {step['operation']}...")
+ await self.screenshot_step("before-" + str(step_n))
+ await self.save_step_html("before-" + str(step_n))
+
+ try:
+ optional_value = step['optional_value']
+ selector = step['selector']
+ # Support for jinja2 template in step values, with date module added
+ if '{%' in step['optional_value'] or '{{' in step['optional_value']:
+ optional_value = jinja_render(template_str=step['optional_value'])
+ if '{%' in step['selector'] or '{{' in step['selector']:
+ selector = jinja_render(template_str=step['selector'])
+
+ await getattr(interface, "call_action")(action_name=step['operation'],
+ selector=selector,
+ optional_value=optional_value)
+ await self.screenshot_step(step_n)
+ await self.save_step_html(step_n)
+ except (Error, TimeoutError) as e:
+ logger.debug(str(e))
+ # Stop processing here
+ raise BrowserStepsStepException(step_n=step_n, original_e=e)
+
+ # It's always good to reset these
+ def delete_browser_steps_screenshots(self):
+ import glob
+ if self.browser_steps_screenshot_path is not None:
+ dest = os.path.join(self.browser_steps_screenshot_path, 'step_*.jpeg')
+ files = glob.glob(dest)
+ for f in files:
+ if os.path.isfile(f):
+ os.unlink(f)
+
+ def save_step_html(self, step_n):
+ if self.browser_steps_screenshot_path and not os.path.isdir(self.browser_steps_screenshot_path):
+ logger.debug(f"> Creating data dir {self.browser_steps_screenshot_path}")
+ os.mkdir(self.browser_steps_screenshot_path)
+ pass
diff --git a/changedetectionio/content_fetchers/exceptions/__init__.py b/changedetectionio/content_fetchers/exceptions/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..80ebae69e09315684fb0edb82607bf32c740ab48
--- /dev/null
+++ b/changedetectionio/content_fetchers/exceptions/__init__.py
@@ -0,0 +1,97 @@
+from loguru import logger
+
+class Non200ErrorCodeReceived(Exception):
+ def __init__(self, status_code, url, screenshot=None, xpath_data=None, page_html=None):
+ # Set this so we can use it in other parts of the app
+ self.status_code = status_code
+ self.url = url
+ self.screenshot = screenshot
+ self.xpath_data = xpath_data
+ self.page_text = None
+
+ if page_html:
+ from changedetectionio import html_tools
+ self.page_text = html_tools.html_to_text(page_html)
+ return
+
+
+class checksumFromPreviousCheckWasTheSame(Exception):
+ def __init__(self):
+ return
+
+
+class JSActionExceptions(Exception):
+ def __init__(self, status_code, url, screenshot, message=''):
+ self.status_code = status_code
+ self.url = url
+ self.screenshot = screenshot
+ self.message = message
+ return
+
+class BrowserConnectError(Exception):
+ msg = ''
+ def __init__(self, msg):
+ self.msg = msg
+ logger.error(f"Browser connection error {msg}")
+ return
+
+class BrowserFetchTimedOut(Exception):
+ msg = ''
+ def __init__(self, msg):
+ self.msg = msg
+ logger.error(f"Browser processing took too long - {msg}")
+ return
+
+class BrowserStepsStepException(Exception):
+ def __init__(self, step_n, original_e):
+ self.step_n = step_n
+ self.original_e = original_e
+ logger.debug(f"Browser Steps exception at step {self.step_n} {str(original_e)}")
+ return
+
+
+# @todo - make base Exception class that announces via logger()
+class PageUnloadable(Exception):
+ def __init__(self, status_code=None, url='', message='', screenshot=False):
+ # Set this so we can use it in other parts of the app
+ self.status_code = status_code
+ self.url = url
+ self.screenshot = screenshot
+ self.message = message
+ return
+
+class BrowserStepsInUnsupportedFetcher(Exception):
+ def __init__(self, url):
+ self.url = url
+ return
+
+class EmptyReply(Exception):
+ def __init__(self, status_code, url, screenshot=None):
+ # Set this so we can use it in other parts of the app
+ self.status_code = status_code
+ self.url = url
+ self.screenshot = screenshot
+ return
+
+
+class ScreenshotUnavailable(Exception):
+ def __init__(self, status_code, url, page_html=None):
+ # Set this so we can use it in other parts of the app
+ self.status_code = status_code
+ self.url = url
+ if page_html:
+ from changedetectionio.html_tools import html_to_text
+ self.page_text = html_to_text(page_html)
+ return
+
+
+class ReplyWithContentButNoText(Exception):
+ def __init__(self, status_code, url, screenshot=None, has_filters=False, html_content='', xpath_data=None):
+ # Set this so we can use it in other parts of the app
+ self.status_code = status_code
+ self.url = url
+ self.screenshot = screenshot
+ self.has_filters = has_filters
+ self.html_content = html_content
+ self.xpath_data = xpath_data
+ return
diff --git a/changedetectionio/content_fetchers/playwright.py b/changedetectionio/content_fetchers/playwright.py
new file mode 100644
index 0000000000000000000000000000000000000000..c5b5bd31f0532e3188385cbb3d5cb6cada7af541
--- /dev/null
+++ b/changedetectionio/content_fetchers/playwright.py
@@ -0,0 +1,323 @@
+import json
+import os
+from urllib.parse import urlparse
+
+from loguru import logger
+
+from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \
+ SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_MAX_TOTAL_HEIGHT, XPATH_ELEMENT_JS, INSTOCK_DATA_JS
+from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
+from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable
+
+async def capture_full_page_async(page):
+ import os
+ import time
+ from multiprocessing import Process, Pipe
+
+ start = time.time()
+
+ page_height = await page.evaluate("document.documentElement.scrollHeight")
+ page_width = await page.evaluate("document.documentElement.scrollWidth")
+ original_viewport = page.viewport_size
+
+ logger.debug(f"Playwright viewport size {page.viewport_size} page height {page_height} page width {page_width}")
+
+ # Use an approach similar to puppeteer: set a larger viewport and take screenshots in chunks
+ step_size = SCREENSHOT_SIZE_STITCH_THRESHOLD # Size that won't cause GPU to overflow
+ screenshot_chunks = []
+ y = 0
+
+ if page_height > page.viewport_size['height']:
+ if page_height < step_size:
+ step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size
+ logger.debug(f"Setting bigger viewport to step through large page width W{page.viewport_size['width']}xH{step_size} because page_height > viewport_size")
+ # Set viewport to a larger size to capture more content at once
+ await page.set_viewport_size({'width': page.viewport_size['width'], 'height': step_size})
+
+ # Capture screenshots in chunks up to the max total height
+ while y < min(page_height, SCREENSHOT_MAX_TOTAL_HEIGHT):
+ await page.request_gc()
+ await page.evaluate(f"window.scrollTo(0, {y})")
+ await page.request_gc()
+ screenshot_chunks.append(await page.screenshot(
+ type="jpeg",
+ full_page=False,
+ quality=int(os.getenv("SCREENSHOT_QUALITY", 72))
+ ))
+ y += step_size
+ await page.request_gc()
+
+ # Restore original viewport size
+ await page.set_viewport_size({'width': original_viewport['width'], 'height': original_viewport['height']})
+
+ # If we have multiple chunks, stitch them together
+ if len(screenshot_chunks) > 1:
+ from changedetectionio.content_fetchers.screenshot_handler import stitch_images_worker
+ logger.debug(f"Screenshot stitching {len(screenshot_chunks)} chunks together")
+ parent_conn, child_conn = Pipe()
+ p = Process(target=stitch_images_worker, args=(child_conn, screenshot_chunks, page_height, SCREENSHOT_MAX_TOTAL_HEIGHT))
+ p.start()
+ screenshot = parent_conn.recv_bytes()
+ p.join()
+ logger.debug(
+ f"Screenshot (chunked/stitched) - Page height: {page_height} Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT} - Stitched together in {time.time() - start:.2f}s")
+ # Explicit cleanup
+ del screenshot_chunks
+ del p
+ del parent_conn, child_conn
+ screenshot_chunks = None
+ return screenshot
+
+ logger.debug(
+ f"Screenshot Page height: {page_height} Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT} - Stitched together in {time.time() - start:.2f}s")
+
+ return screenshot_chunks[0]
+
+class fetcher(Fetcher):
+ fetcher_description = "Playwright {}/Javascript".format(
+ os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize()
+ )
+ if os.getenv("PLAYWRIGHT_DRIVER_URL"):
+ fetcher_description += " via '{}'".format(os.getenv("PLAYWRIGHT_DRIVER_URL"))
+
+ browser_type = ''
+ command_executor = ''
+
+ # Configs for Proxy setup
+ # In the ENV vars, is prefixed with "playwright_proxy_", so it is for example "playwright_proxy_server"
+ playwright_proxy_settings_mappings = ['bypass', 'server', 'username', 'password']
+
+ proxy = None
+
+ def __init__(self, proxy_override=None, custom_browser_connection_url=None):
+ super().__init__()
+
+ self.browser_type = os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').strip('"')
+
+ if custom_browser_connection_url:
+ self.browser_connection_is_custom = True
+ self.browser_connection_url = custom_browser_connection_url
+ else:
+ # Fallback to fetching from system
+ # .strip('"') is going to save someone a lot of time when they accidently wrap the env value
+ self.browser_connection_url = os.getenv("PLAYWRIGHT_DRIVER_URL", 'ws://playwright-chrome:3000').strip('"')
+
+ # If any proxy settings are enabled, then we should setup the proxy object
+ proxy_args = {}
+ for k in self.playwright_proxy_settings_mappings:
+ v = os.getenv('playwright_proxy_' + k, False)
+ if v:
+ proxy_args[k] = v.strip('"')
+
+ if proxy_args:
+ self.proxy = proxy_args
+
+ # allow per-watch proxy selection override
+ if proxy_override:
+ self.proxy = {'server': proxy_override}
+
+ if self.proxy:
+ # Playwright needs separate username and password values
+ parsed = urlparse(self.proxy.get('server'))
+ if parsed.username:
+ self.proxy['username'] = parsed.username
+ self.proxy['password'] = parsed.password
+
+ async def screenshot_step(self, step_n=''):
+ super().screenshot_step(step_n=step_n)
+ screenshot = await capture_full_page_async(page=self.page)
+
+
+ if self.browser_steps_screenshot_path is not None:
+ destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n))
+ logger.debug(f"Saving step screenshot to {destination}")
+ with open(destination, 'wb') as f:
+ f.write(screenshot)
+
+ async def save_step_html(self, step_n):
+ super().save_step_html(step_n=step_n)
+ content = await self.page.content()
+ destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.html'.format(step_n))
+ logger.debug(f"Saving step HTML to {destination}")
+ with open(destination, 'w') as f:
+ f.write(content)
+
+ async def run(self,
+ url,
+ timeout,
+ request_headers,
+ request_body,
+ request_method,
+ ignore_status_codes=False,
+ current_include_filters=None,
+ is_binary=False,
+ empty_pages_are_a_change=False):
+
+ from playwright.async_api import async_playwright
+ import playwright._impl._errors
+ import time
+ self.delete_browser_steps_screenshots()
+ response = None
+
+ async with async_playwright() as p:
+ browser_type = getattr(p, self.browser_type)
+
+ # Seemed to cause a connection Exception even tho I can see it connect
+ # self.browser = browser_type.connect(self.command_executor, timeout=timeout*1000)
+ # 60,000 connection timeout only
+ browser = await browser_type.connect_over_cdp(self.browser_connection_url, timeout=60000)
+
+ # SOCKS5 with authentication is not supported (yet)
+ # https://github.com/microsoft/playwright/issues/10567
+
+ # Set user agent to prevent Cloudflare from blocking the browser
+ # Use the default one configured in the App.py model that's passed from fetch_site_status.py
+ context = await browser.new_context(
+ accept_downloads=False, # Should never be needed
+ bypass_csp=True, # This is needed to enable JavaScript execution on GitHub and others
+ extra_http_headers=request_headers,
+ ignore_https_errors=True,
+ proxy=self.proxy,
+ service_workers=os.getenv('PLAYWRIGHT_SERVICE_WORKERS', 'allow'), # Should be `allow` or `block` - sites like YouTube can transmit large amounts of data via Service Workers
+ user_agent=manage_user_agent(headers=request_headers),
+ )
+
+ self.page = await context.new_page()
+
+ # Listen for all console events and handle errors
+ self.page.on("console", lambda msg: logger.debug(f"Playwright console: Watch URL: {url} {msg.type}: {msg.text} {msg.args}"))
+
+ # Re-use as much code from browser steps as possible so its the same
+ from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface
+ browsersteps_interface = steppable_browser_interface(start_url=url)
+ browsersteps_interface.page = self.page
+
+ response = await browsersteps_interface.action_goto_url(value=url)
+
+ if response is None:
+ await context.close()
+ await browser.close()
+ logger.debug("Content Fetcher > Response object from the browser communication was none")
+ raise EmptyReply(url=url, status_code=None)
+
+ # In async_playwright, all_headers() returns a coroutine
+ try:
+ self.headers = await response.all_headers()
+ except TypeError:
+ # Fallback for sync version
+ self.headers = response.all_headers()
+
+ try:
+ if self.webdriver_js_execute_code is not None and len(self.webdriver_js_execute_code):
+ await browsersteps_interface.action_execute_js(value=self.webdriver_js_execute_code, selector=None)
+ except playwright._impl._errors.TimeoutError as e:
+ await context.close()
+ await browser.close()
+ # This can be ok, we will try to grab what we could retrieve
+ pass
+ except Exception as e:
+ logger.debug(f"Content Fetcher > Other exception when executing custom JS code {str(e)}")
+ await context.close()
+ await browser.close()
+ raise PageUnloadable(url=url, status_code=None, message=str(e))
+
+ extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
+ await self.page.wait_for_timeout(extra_wait * 1000)
+
+ try:
+ self.status_code = response.status
+ except Exception as e:
+ # https://github.com/dgtlmoon/changedetection.io/discussions/2122#discussioncomment-8241962
+ logger.critical(f"Response from the browser/Playwright did not have a status_code! Response follows.")
+ logger.critical(response)
+ await context.close()
+ await browser.close()
+ raise PageUnloadable(url=url, status_code=None, message=str(e))
+
+ if self.status_code != 200 and not ignore_status_codes:
+ screenshot = await capture_full_page_async(self.page)
+ raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
+
+ if not empty_pages_are_a_change and len((await self.page.content()).strip()) == 0:
+ logger.debug("Content Fetcher > Content was empty, empty_pages_are_a_change = False")
+ await context.close()
+ await browser.close()
+ raise EmptyReply(url=url, status_code=response.status)
+
+ # Run Browser Steps here
+ if self.browser_steps_get_valid_steps():
+ await self.iterate_browser_steps(start_url=url)
+
+ await self.page.wait_for_timeout(extra_wait * 1000)
+
+ now = time.time()
+ # So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
+ if current_include_filters is not None:
+ await self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters)))
+ else:
+ await self.page.evaluate("var include_filters=''")
+ await self.page.request_gc()
+
+ # request_gc before and after evaluate to free up memory
+ # @todo browsersteps etc
+ MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
+ self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
+ "visualselector_xpath_selectors": visualselector_xpath_selectors,
+ "max_height": MAX_TOTAL_HEIGHT
+ })
+ await self.page.request_gc()
+
+ self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)
+ await self.page.request_gc()
+
+ self.content = await self.page.content()
+ await self.page.request_gc()
+ logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
+
+ # Bug 3 in Playwright screenshot handling
+ # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
+ # JPEG is better here because the screenshots can be very very large
+
+ # Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
+ # which will significantly increase the IO size between the server and client, it's recommended to use the lowest
+ # acceptable screenshot quality here
+ try:
+ # The actual screenshot - this always base64 and needs decoding! horrible! huge CPU usage
+ self.screenshot = await capture_full_page_async(page=self.page)
+
+ except Exception as e:
+ # It's likely the screenshot was too long/big and something crashed
+ raise ScreenshotUnavailable(url=url, status_code=self.status_code)
+ finally:
+ # Request garbage collection one more time before closing
+ try:
+ await self.page.request_gc()
+ except:
+ pass
+
+ # Clean up resources properly
+ try:
+ await self.page.request_gc()
+ except:
+ pass
+
+ try:
+ await self.page.close()
+ except:
+ pass
+ self.page = None
+
+ try:
+ await context.close()
+ except:
+ pass
+ context = None
+
+ try:
+ await browser.close()
+ except:
+ pass
+ browser = None
+
+
+
diff --git a/changedetectionio/content_fetchers/puppeteer.py b/changedetectionio/content_fetchers/puppeteer.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a165b0c3a4ae4d2f686df2f70e567f992d6fc5c
--- /dev/null
+++ b/changedetectionio/content_fetchers/puppeteer.py
@@ -0,0 +1,367 @@
+import asyncio
+import json
+import os
+import websockets.exceptions
+from urllib.parse import urlparse
+
+from loguru import logger
+
+from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \
+ SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_DEFAULT_QUALITY, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, \
+ SCREENSHOT_MAX_TOTAL_HEIGHT
+from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
+from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, \
+ BrowserConnectError
+
+
+# Bug 3 in Playwright screenshot handling
+# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
+
+# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
+# which will significantly increase the IO size between the server and client, it's recommended to use the lowest
+# acceptable screenshot quality here
+async def capture_full_page(page):
+ import os
+ import time
+ from multiprocessing import Process, Pipe
+
+ start = time.time()
+
+ page_height = await page.evaluate("document.documentElement.scrollHeight")
+ page_width = await page.evaluate("document.documentElement.scrollWidth")
+ original_viewport = page.viewport
+
+ logger.debug(f"Puppeteer viewport size {page.viewport} page height {page_height} page width {page_width}")
+
+ # Bug 3 in Playwright screenshot handling
+ # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
+ # JPEG is better here because the screenshots can be very very large
+
+ # Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
+ # which will significantly increase the IO size between the server and client, it's recommended to use the lowest
+ # acceptable screenshot quality here
+
+
+ step_size = SCREENSHOT_SIZE_STITCH_THRESHOLD # Something that will not cause the GPU to overflow when taking the screenshot
+ screenshot_chunks = []
+ y = 0
+ if page_height > page.viewport['height']:
+ if page_height < step_size:
+ step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size
+ await page.setViewport({'width': page.viewport['width'], 'height': step_size})
+
+ while y < min(page_height, SCREENSHOT_MAX_TOTAL_HEIGHT):
+ # better than scrollTo incase they override it in the page
+ await page.evaluate(
+ """(y) => {
+ document.documentElement.scrollTop = y;
+ document.body.scrollTop = y;
+ }""",
+ y
+ )
+
+ screenshot_chunks.append(await page.screenshot(type_='jpeg',
+ fullPage=False,
+ quality=int(os.getenv("SCREENSHOT_QUALITY", 72))))
+ y += step_size
+
+ await page.setViewport({'width': original_viewport['width'], 'height': original_viewport['height']})
+
+ if len(screenshot_chunks) > 1:
+ from changedetectionio.content_fetchers.screenshot_handler import stitch_images_worker
+ logger.debug(f"Screenshot stitching {len(screenshot_chunks)} chunks together")
+ parent_conn, child_conn = Pipe()
+ p = Process(target=stitch_images_worker, args=(child_conn, screenshot_chunks, page_height, SCREENSHOT_MAX_TOTAL_HEIGHT))
+ p.start()
+ screenshot = parent_conn.recv_bytes()
+ p.join()
+ logger.debug(
+ f"Screenshot (chunked/stitched) - Page height: {page_height} Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT} - Stitched together in {time.time() - start:.2f}s")
+
+ screenshot_chunks = None
+ return screenshot
+
+ logger.debug(
+ f"Screenshot Page height: {page_height} Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT} - Stitched together in {time.time() - start:.2f}s")
+ return screenshot_chunks[0]
+
+
+class fetcher(Fetcher):
+ fetcher_description = "Puppeteer/direct {}/Javascript".format(
+ os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize()
+ )
+ if os.getenv("PLAYWRIGHT_DRIVER_URL"):
+ fetcher_description += " via '{}'".format(os.getenv("PLAYWRIGHT_DRIVER_URL"))
+
+ browser_type = ''
+ command_executor = ''
+
+ proxy = None
+
+ def __init__(self, proxy_override=None, custom_browser_connection_url=None):
+ super().__init__()
+
+ if custom_browser_connection_url:
+ self.browser_connection_is_custom = True
+ self.browser_connection_url = custom_browser_connection_url
+ else:
+ # Fallback to fetching from system
+ # .strip('"') is going to save someone a lot of time when they accidently wrap the env value
+ self.browser_connection_url = os.getenv("PLAYWRIGHT_DRIVER_URL", 'ws://playwright-chrome:3000').strip('"')
+
+ # allow per-watch proxy selection override
+ # @todo check global too?
+ if proxy_override:
+ # Playwright needs separate username and password values
+ parsed = urlparse(proxy_override)
+ if parsed:
+ self.proxy = {'username': parsed.username, 'password': parsed.password}
+ # Add the proxy server chrome start option, the username and password never gets added here
+ # (It always goes in via await self.page.authenticate(self.proxy))
+
+ # @todo filter some injection attack?
+ # check scheme when no scheme
+ proxy_url = parsed.scheme + "://" if parsed.scheme else 'http://'
+ r = "?" if not '?' in self.browser_connection_url else '&'
+ port = ":"+str(parsed.port) if parsed.port else ''
+ q = "?"+parsed.query if parsed.query else ''
+ proxy_url += f"{parsed.hostname}{port}{parsed.path}{q}"
+ self.browser_connection_url += f"{r}--proxy-server={proxy_url}"
+
+ # def screenshot_step(self, step_n=''):
+ # screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=85)
+ #
+ # if self.browser_steps_screenshot_path is not None:
+ # destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n))
+ # logger.debug(f"Saving step screenshot to {destination}")
+ # with open(destination, 'wb') as f:
+ # f.write(screenshot)
+ #
+ # def save_step_html(self, step_n):
+ # content = self.page.content()
+ # destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.html'.format(step_n))
+ # logger.debug(f"Saving step HTML to {destination}")
+ # with open(destination, 'w') as f:
+ # f.write(content)
+
+ async def fetch_page(self,
+ url,
+ timeout,
+ request_headers,
+ request_body,
+ request_method,
+ ignore_status_codes,
+ current_include_filters,
+ is_binary,
+ empty_pages_are_a_change
+ ):
+ import re
+ self.delete_browser_steps_screenshots()
+
+ n = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
+ extra_wait = min(n, 15)
+
+ logger.debug(f"Extra wait set to {extra_wait}s, requested was {n}s.")
+
+ from pyppeteer import Pyppeteer
+ pyppeteer_instance = Pyppeteer()
+
+ # Connect directly using the specified browser_ws_endpoint
+ # @todo timeout
+ try:
+ browser = await pyppeteer_instance.connect(browserWSEndpoint=self.browser_connection_url,
+ ignoreHTTPSErrors=True
+ )
+ except websockets.exceptions.InvalidStatusCode as e:
+ raise BrowserConnectError(msg=f"Error while trying to connect the browser, Code {e.status_code} (check your access, whitelist IP, password etc)")
+ except websockets.exceptions.InvalidURI:
+ raise BrowserConnectError(msg=f"Error connecting to the browser, check your browser connection address (should be ws:// or wss://")
+ except Exception as e:
+ raise BrowserConnectError(msg=f"Error connecting to the browser - Exception '{str(e)}'")
+
+ # Better is to launch chrome with the URL as arg
+ # non-headless - newPage() will launch an extra tab/window, .browser should already contain 1 page/tab
+ # headless - ask a new page
+ self.page = (pages := await browser.pages) and len(pages) or await browser.newPage()
+
+ if '--window-size' in self.browser_connection_url:
+ # Be sure the viewport is always the window-size, this is often not the same thing
+ match = re.search(r'--window-size=(\d+),(\d+)', self.browser_connection_url)
+ if match:
+ logger.debug(f"Setting viewport to same as --window-size in browser connection URL {int(match.group(1))},{int(match.group(2))}")
+ await self.page.setViewport({
+ "width": int(match.group(1)),
+ "height": int(match.group(2))
+ })
+ logger.debug(f"Puppeteer viewport size {self.page.viewport}")
+
+ try:
+ from pyppeteerstealth import inject_evasions_into_page
+ except ImportError:
+ logger.debug("pyppeteerstealth module not available, skipping")
+ pass
+ else:
+ # I tried hooking events via self.page.on(Events.Page.DOMContentLoaded, inject_evasions_requiring_obj_to_page)
+ # But I could never get it to fire reliably, so we just inject it straight after
+ await inject_evasions_into_page(self.page)
+
+ # This user agent is similar to what was used when tweaking the evasions in inject_evasions_into_page(..)
+ user_agent = None
+ if request_headers and request_headers.get('User-Agent'):
+ # Request_headers should now be CaaseInsensitiveDict
+ # Remove it so it's not sent again with headers after
+ user_agent = request_headers.pop('User-Agent').strip()
+ await self.page.setUserAgent(user_agent)
+
+ if not user_agent:
+ # Attempt to strip 'HeadlessChrome' etc
+ await self.page.setUserAgent(manage_user_agent(headers=request_headers, current_ua=await self.page.evaluate('navigator.userAgent')))
+
+ await self.page.setBypassCSP(True)
+ if request_headers:
+ await self.page.setExtraHTTPHeaders(request_headers)
+
+ # SOCKS5 with authentication is not supported (yet)
+ # https://github.com/microsoft/playwright/issues/10567
+ self.page.setDefaultNavigationTimeout(0)
+ await self.page.setCacheEnabled(True)
+ if self.proxy and self.proxy.get('username'):
+ # Setting Proxy-Authentication header is deprecated, and doing so can trigger header change errors from Puppeteer
+ # https://github.com/puppeteer/puppeteer/issues/676 ?
+ # https://help.brightdata.com/hc/en-us/articles/12632549957649-Proxy-Manager-How-to-Guides#h_01HAKWR4Q0AFS8RZTNYWRDFJC2
+ # https://cri.dev/posts/2020-03-30-How-to-solve-Puppeteer-Chrome-Error-ERR_INVALID_ARGUMENT/
+ await self.page.authenticate(self.proxy)
+
+ # Re-use as much code from browser steps as possible so its the same
+ # from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface
+
+ # not yet used here, we fallback to playwright when browsersteps is required
+ # browsersteps_interface = steppable_browser_interface()
+ # browsersteps_interface.page = self.page
+
+ async def handle_frame_navigation(event):
+ logger.debug(f"Frame navigated: {event}")
+ w = extra_wait - 2 if extra_wait > 4 else 2
+ logger.debug(f"Waiting {w} seconds before calling Page.stopLoading...")
+ await asyncio.sleep(w)
+ logger.debug("Issuing stopLoading command...")
+ await self.page._client.send('Page.stopLoading')
+ logger.debug("stopLoading command sent!")
+
+ self.page._client.on('Page.frameStartedNavigating', lambda event: asyncio.create_task(handle_frame_navigation(event)))
+ self.page._client.on('Page.frameStartedLoading', lambda event: asyncio.create_task(handle_frame_navigation(event)))
+ self.page._client.on('Page.frameStoppedLoading', lambda event: logger.debug(f"Frame stopped loading: {event}"))
+
+ response = None
+ attempt=0
+ while not response:
+ logger.debug(f"Attempting page fetch {url} attempt {attempt}")
+ response = await self.page.goto(url)
+ await asyncio.sleep(1 + extra_wait)
+ if response:
+ break
+ if not response:
+ logger.warning("Page did not fetch! trying again!")
+ if response is None and attempt>=2:
+ await self.page.close()
+ await browser.close()
+ logger.warning(f"Content Fetcher > Response object was none (as in, the response from the browser was empty, not just the content) exiting attmpt {attempt}")
+ raise EmptyReply(url=url, status_code=None)
+ attempt+=1
+
+ self.headers = response.headers
+
+ try:
+ if self.webdriver_js_execute_code is not None and len(self.webdriver_js_execute_code):
+ await self.page.evaluate(self.webdriver_js_execute_code)
+ except Exception as e:
+ logger.warning("Got exception when running evaluate on custom JS code")
+ logger.error(str(e))
+ await self.page.close()
+ await browser.close()
+ # This can be ok, we will try to grab what we could retrieve
+ raise PageUnloadable(url=url, status_code=None, message=str(e))
+
+ try:
+ self.status_code = response.status
+ except Exception as e:
+ # https://github.com/dgtlmoon/changedetection.io/discussions/2122#discussioncomment-8241962
+ logger.critical(f"Response from the browser/Playwright did not have a status_code! Response follows.")
+ logger.critical(response)
+ await self.page.close()
+ await browser.close()
+ raise PageUnloadable(url=url, status_code=None, message=str(e))
+
+ if self.status_code != 200 and not ignore_status_codes:
+ screenshot = await capture_full_page(page=self.page)
+
+ raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
+
+ content = await self.page.content
+
+ if not empty_pages_are_a_change and len(content.strip()) == 0:
+ logger.error("Content Fetcher > Content was empty (empty_pages_are_a_change is False), closing browsers")
+ await self.page.close()
+ await browser.close()
+ raise EmptyReply(url=url, status_code=response.status)
+
+ # Run Browser Steps here
+ # @todo not yet supported, we switch to playwright in this case
+ # if self.browser_steps_get_valid_steps():
+ # self.iterate_browser_steps()
+
+
+ # So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
+ # Setup the xPath/VisualSelector scraper
+ if current_include_filters:
+ js = json.dumps(current_include_filters)
+ await self.page.evaluate(f"var include_filters={js}")
+ else:
+ await self.page.evaluate(f"var include_filters=''")
+
+ MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
+ self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
+ "visualselector_xpath_selectors": visualselector_xpath_selectors,
+ "max_height": MAX_TOTAL_HEIGHT
+ })
+ if not self.xpath_data:
+ raise Exception(f"Content Fetcher > xPath scraper failed. Please report this URL so we can fix it :)")
+
+ self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)
+
+ self.content = await self.page.content
+
+ self.screenshot = await capture_full_page(page=self.page)
+
+ # It's good to log here in the case that the browser crashes on shutting down but we still get the data we need
+ logger.success(f"Fetching '{url}' complete, closing page")
+ await self.page.close()
+ logger.success(f"Fetching '{url}' complete, closing browser")
+ await browser.close()
+ logger.success(f"Fetching '{url}' complete, exiting puppeteer fetch.")
+
+ async def main(self, **kwargs):
+ await self.fetch_page(**kwargs)
+
+ async def run(self, url, timeout, request_headers, request_body, request_method, ignore_status_codes=False,
+ current_include_filters=None, is_binary=False, empty_pages_are_a_change=False):
+
+ #@todo make update_worker async which could run any of these content_fetchers within memory and time constraints
+ max_time = int(os.getenv('PUPPETEER_MAX_PROCESSING_TIMEOUT_SECONDS', 180))
+
+ # Now we run this properly in async context since we're called from async worker
+ try:
+ await asyncio.wait_for(self.main(
+ url=url,
+ timeout=timeout,
+ request_headers=request_headers,
+ request_body=request_body,
+ request_method=request_method,
+ ignore_status_codes=ignore_status_codes,
+ current_include_filters=current_include_filters,
+ is_binary=is_binary,
+ empty_pages_are_a_change=empty_pages_are_a_change
+ ), timeout=max_time)
+ except asyncio.TimeoutError:
+ raise(BrowserFetchTimedOut(msg=f"Browser connected but was unable to process the page in {max_time} seconds."))
+
diff --git a/changedetectionio/content_fetchers/requests.py b/changedetectionio/content_fetchers/requests.py
new file mode 100644
index 0000000000000000000000000000000000000000..aba5ed0d041e62e15f854220e02dd427c4ee2c0a
--- /dev/null
+++ b/changedetectionio/content_fetchers/requests.py
@@ -0,0 +1,148 @@
+from loguru import logger
+import hashlib
+import os
+import asyncio
+from changedetectionio import strtobool
+from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
+from changedetectionio.content_fetchers.base import Fetcher
+
+
+# "html_requests" is listed as the default fetcher in store.py!
+class fetcher(Fetcher):
+ fetcher_description = "Basic fast Plaintext/HTTP Client"
+
+ def __init__(self, proxy_override=None, custom_browser_connection_url=None):
+ super().__init__()
+ self.proxy_override = proxy_override
+ # browser_connection_url is none because its always 'launched locally'
+
+ def _run_sync(self,
+ url,
+ timeout,
+ request_headers,
+ request_body,
+ request_method,
+ ignore_status_codes=False,
+ current_include_filters=None,
+ is_binary=False,
+ empty_pages_are_a_change=False):
+ """Synchronous version of run - the original requests implementation"""
+
+ import chardet
+ import requests
+ from requests.exceptions import ProxyError, ConnectionError, RequestException
+
+ if self.browser_steps_get_valid_steps():
+ raise BrowserStepsInUnsupportedFetcher(url=url)
+
+ proxies = {}
+
+ # Allows override the proxy on a per-request basis
+ # https://requests.readthedocs.io/en/latest/user/advanced/#socks
+ # Should also work with `socks5://user:pass@host:port` type syntax.
+
+ if self.proxy_override:
+ proxies = {'http': self.proxy_override, 'https': self.proxy_override, 'ftp': self.proxy_override}
+ else:
+ if self.system_http_proxy:
+ proxies['http'] = self.system_http_proxy
+ if self.system_https_proxy:
+ proxies['https'] = self.system_https_proxy
+
+ session = requests.Session()
+
+ if strtobool(os.getenv('ALLOW_FILE_URI', 'false')) and url.startswith('file://'):
+ from requests_file import FileAdapter
+ session.mount('file://', FileAdapter())
+ try:
+ r = session.request(method=request_method,
+ data=request_body.encode('utf-8') if type(request_body) is str else request_body,
+ url=url,
+ headers=request_headers,
+ timeout=timeout,
+ proxies=proxies,
+ verify=False)
+ except Exception as e:
+ msg = str(e)
+ if proxies and 'SOCKSHTTPSConnectionPool' in msg:
+ msg = f"Proxy connection failed? {msg}"
+ raise Exception(msg) from e
+
+ # If the response did not tell us what encoding format to expect, Then use chardet to override what `requests` thinks.
+ # For example - some sites don't tell us it's utf-8, but return utf-8 content
+ # This seems to not occur when using webdriver/selenium, it seems to detect the text encoding more reliably.
+ # https://github.com/psf/requests/issues/1604 good info about requests encoding detection
+ if not is_binary:
+ # Don't run this for PDF (and requests identified as binary) takes a _long_ time
+ if not r.headers.get('content-type') or not 'charset=' in r.headers.get('content-type'):
+ encoding = chardet.detect(r.content)['encoding']
+ if encoding:
+ r.encoding = encoding
+
+ self.headers = r.headers
+
+ if not r.content or not len(r.content):
+ logger.debug(f"Requests returned empty content for '{url}'")
+ if not empty_pages_are_a_change:
+ raise EmptyReply(url=url, status_code=r.status_code)
+ else:
+ logger.debug(f"URL {url} gave zero byte content reply with Status Code {r.status_code}, but empty_pages_are_a_change = True")
+
+ # @todo test this
+ # @todo maybe you really want to test zero-byte return pages?
+ if r.status_code != 200 and not ignore_status_codes:
+ # maybe check with content works?
+ raise Non200ErrorCodeReceived(url=url, status_code=r.status_code, page_html=r.text)
+
+ self.status_code = r.status_code
+ if is_binary:
+ # Binary files just return their checksum until we add something smarter
+ self.content = hashlib.md5(r.content).hexdigest()
+ else:
+ self.content = r.text
+
+ self.raw_content = r.content
+
+ async def run(self,
+ url,
+ timeout,
+ request_headers,
+ request_body,
+ request_method,
+ ignore_status_codes=False,
+ current_include_filters=None,
+ is_binary=False,
+ empty_pages_are_a_change=False):
+ """Async wrapper that runs the synchronous requests code in a thread pool"""
+
+ loop = asyncio.get_event_loop()
+
+ # Run the synchronous _run_sync in a thread pool to avoid blocking the event loop
+ await loop.run_in_executor(
+ None, # Use default ThreadPoolExecutor
+ lambda: self._run_sync(
+ url=url,
+ timeout=timeout,
+ request_headers=request_headers,
+ request_body=request_body,
+ request_method=request_method,
+ ignore_status_codes=ignore_status_codes,
+ current_include_filters=current_include_filters,
+ is_binary=is_binary,
+ empty_pages_are_a_change=empty_pages_are_a_change
+ )
+ )
+
+ def quit(self, watch=None):
+
+ # In case they switched to `requests` fetcher from something else
+ # Then the screenshot could be old, in any case, it's not used here.
+ # REMOVE_REQUESTS_OLD_SCREENSHOTS - Mainly used for testing
+ if strtobool(os.getenv("REMOVE_REQUESTS_OLD_SCREENSHOTS", 'true')):
+ screenshot = watch.get_screenshot()
+ if screenshot:
+ try:
+ os.unlink(screenshot)
+ except Exception as e:
+ logger.warning(f"Failed to unlink screenshot: {screenshot} - {e}")
+
diff --git a/changedetectionio/content_fetchers/res/__init__.py b/changedetectionio/content_fetchers/res/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b41a98a51be5bf143661bacb45f0b4372a03405b
--- /dev/null
+++ b/changedetectionio/content_fetchers/res/__init__.py
@@ -0,0 +1 @@
+# resources for browser injection/scraping
diff --git a/changedetectionio/content_fetchers/res/stock-not-in-stock.js b/changedetectionio/content_fetchers/res/stock-not-in-stock.js
new file mode 100644
index 0000000000000000000000000000000000000000..46ef061848f40780a4486f7f10c25b729dcd72fa
--- /dev/null
+++ b/changedetectionio/content_fetchers/res/stock-not-in-stock.js
@@ -0,0 +1,236 @@
+async () => {
+
+ function isItemInStock() {
+ // @todo Pass these in so the same list can be used in non-JS fetchers
+ const outOfStockTexts = [
+ ' أخبرني عندما يتوفر',
+ '0 in stock',
+ 'actuellement indisponible',
+ 'agotado',
+ 'article épuisé',
+ 'artikel zurzeit vergriffen',
+ 'as soon as stock is available',
+ 'aucune offre n\'est disponible',
+ 'ausverkauft', // sold out
+ 'available for back order',
+ 'awaiting stock',
+ 'back in stock soon',
+ 'back-order or out of stock',
+ 'backordered',
+ 'benachrichtigt mich', // notify me
+ 'binnenkort leverbaar', // coming soon
+ 'brak na stanie',
+ 'brak w magazynie',
+ 'coming soon',
+ 'currently have any tickets for this',
+ 'currently unavailable',
+ 'dieser artikel ist bald wieder verfügbar',
+ 'dostępne wkrótce',
+ 'en rupture',
+ 'esgotado',
+ 'in kürze lieferbar',
+ 'indisponible',
+ 'indisponível',
+ 'isn\'t in stock right now',
+ 'isnt in stock right now',
+ 'isn’t in stock right now',
+ 'item is no longer available',
+ 'let me know when it\'s available',
+ 'mail me when available',
+ 'message if back in stock',
+ 'mevcut değil',
+ 'nachricht bei',
+ 'nicht auf lager',
+ 'nicht lagernd',
+ 'nicht lieferbar',
+ 'nicht verfügbar',
+ 'nicht vorrätig',
+ 'nicht zur verfügung',
+ 'nie znaleziono produktów',
+ 'niet beschikbaar',
+ 'niet leverbaar',
+ 'niet op voorraad',
+ 'no disponible',
+ 'no featured offers available',
+ 'no longer available',
+ 'no longer in stock',
+ 'no tickets available',
+ 'non disponibile',
+ 'non disponible',
+ 'not available',
+ 'not currently available',
+ 'not in stock',
+ 'notify me when available',
+ 'notify me',
+ 'notify when available',
+ 'não disponível',
+ 'não estamos a aceitar encomendas',
+ 'out of stock',
+ 'out-of-stock',
+ 'plus disponible',
+ 'prodotto esaurito',
+ 'produkt niedostępny',
+ 'rupture',
+ 'sold out',
+ 'sold-out',
+ 'stok habis',
+ 'stok kosong',
+ 'stok varian ini habis',
+ 'stokta yok',
+ 'temporarily out of stock',
+ 'temporarily unavailable',
+ 'there were no search results for',
+ 'this item is currently unavailable',
+ 'tickets unavailable',
+ 'tidak dijual',
+ 'tidak tersedia',
+ 'tijdelijk uitverkocht',
+ 'tiket tidak tersedia',
+ 'to subscribe to back in stock',
+ 'tükendi',
+ 'unavailable nearby',
+ 'unavailable tickets',
+ 'vergriffen',
+ 'vorbestellen',
+ 'vorbestellung ist bald möglich',
+ 'we couldn\'t find any products that match',
+ 'we do not currently have an estimate of when this product will be back in stock.',
+ 'we don\'t currently have any',
+ 'we don\'t know when or if this item will be back in stock.',
+ 'we were not able to find a match',
+ 'when this arrives in stock',
+ 'when this item is available to order',
+ 'zur zeit nicht an lager',
+ 'épuisé',
+ '品切れ',
+ '已售',
+ '已售完',
+ '품절'
+ ];
+
+
+ const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
+
+ function getElementBaseText(element) {
+ // .textContent can include text from children which may give the wrong results
+ // scan only immediate TEXT_NODEs, which will be a child of the element
+ var text = "";
+ for (var i = 0; i < element.childNodes.length; ++i)
+ if (element.childNodes[i].nodeType === Node.TEXT_NODE)
+ text += element.childNodes[i].textContent;
+ return text.toLowerCase().trim();
+ }
+
+ const negateOutOfStockRegex = new RegExp('^([0-9] in stock|add to cart|in stock|arrives approximately)', 'ig');
+ // The out-of-stock or in-stock-text is generally always above-the-fold
+ // and often below-the-fold is a list of related products that may or may not contain trigger text
+ // so it's good to filter to just the 'above the fold' elements
+ // and it should be atleast 100px from the top to ignore items in the toolbar, sometimes menu items like "Coming soon" exist
+
+ function elementIsInEyeBallRange(element) {
+ // outside the 'fold' or some weird text in the heading area
+ // .getBoundingClientRect() was causing a crash in chrome 119, can only be run on contentVisibility != hidden
+ // Note: theres also an automated test that places the 'out of stock' text fairly low down
+ // Skip text that could be in the header area
+ if (element.getBoundingClientRect().bottom + window.scrollY <= 300 ) {
+ return false;
+ }
+ // Skip text that could be much further down (like a list of "you may like" products that have 'sold out' in there
+ if (element.getBoundingClientRect().bottom + window.scrollY >= 1300 ) {
+ return false;
+ }
+ return true;
+ }
+
+// @todo - if it's SVG or IMG, go into image diff mode
+
+ function collectVisibleElements(parent, visibleElements) {
+ if (!parent) return; // Base case: if parent is null or undefined, return
+
+ // Add the parent itself to the visible elements array if it's of the specified types
+ visibleElements.push(parent);
+
+ // Iterate over the parent's children
+ const children = parent.children;
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ if (
+ child.nodeType === Node.ELEMENT_NODE &&
+ window.getComputedStyle(child).display !== 'none' &&
+ window.getComputedStyle(child).visibility !== 'hidden' &&
+ child.offsetWidth >= 0 &&
+ child.offsetHeight >= 0 &&
+ window.getComputedStyle(child).contentVisibility !== 'hidden'
+ ) {
+ // If the child is an element and is visible, recursively collect visible elements
+ collectVisibleElements(child, visibleElements);
+ }
+ }
+ }
+
+ const elementsToScan = [];
+ collectVisibleElements(document.body, elementsToScan);
+
+ var elementText = "";
+
+ // REGEXS THAT REALLY MEAN IT'S IN STOCK
+ for (let i = elementsToScan.length - 1; i >= 0; i--) {
+ const element = elementsToScan[i];
+
+ if (!elementIsInEyeBallRange(element)) {
+ continue
+ }
+
+ elementText = "";
+ try {
+ if (element.tagName.toLowerCase() === "input") {
+ elementText = element.value.toLowerCase().trim();
+ } else {
+ elementText = getElementBaseText(element);
+ }
+ } catch (e) {
+ console.warn('stock-not-in-stock.js scraper - handling element for gettext failed', e);
+ }
+ if (elementText.length) {
+ // try which ones could mean its in stock
+ if (negateOutOfStockRegex.test(elementText) && !elementText.includes('(0 products)')) {
+ console.log(`Negating/overriding 'Out of Stock' back to "Possibly in stock" found "${elementText}"`)
+ element.style.border = "2px solid green"; // highlight the element that was detected as in stock
+ return 'Possibly in stock';
+ }
+ }
+ }
+
+ // OTHER STUFF THAT COULD BE THAT IT'S OUT OF STOCK
+ for (let i = elementsToScan.length - 1; i >= 0; i--) {
+ const element = elementsToScan[i];
+
+ if (!elementIsInEyeBallRange(element)) {
+ continue
+ }
+ elementText = "";
+ if (element.tagName.toLowerCase() === "input") {
+ elementText = element.value.toLowerCase().trim();
+ } else {
+ elementText = getElementBaseText(element);
+ }
+
+ if (elementText.length) {
+ // and these mean its out of stock
+ for (const outOfStockText of outOfStockTexts) {
+ if (elementText.includes(outOfStockText)) {
+ console.log(`Selected 'Out of Stock' - found text "${outOfStockText}" - "${elementText}" - offset top ${element.getBoundingClientRect().top}, page height is ${vh}`)
+ element.style.border = "2px solid red"; // highlight the element that was detected as out of stock
+ return outOfStockText; // item is out of stock
+ }
+ }
+ }
+ }
+
+ console.log(`Returning 'Possibly in stock' - cant' find any useful matching text`)
+ return 'Possibly in stock'; // possibly in stock, cant decide otherwise.
+ }
+
+// returns the element text that makes it think it's out of stock
+ return isItemInStock().trim()
+}
diff --git a/changedetectionio/content_fetchers/res/xpath_element_scraper.js b/changedetectionio/content_fetchers/res/xpath_element_scraper.js
new file mode 100644
index 0000000000000000000000000000000000000000..25bd5ad98d5a7fbf0cf8098899e4259f55b292f3
--- /dev/null
+++ b/changedetectionio/content_fetchers/res/xpath_element_scraper.js
@@ -0,0 +1,284 @@
+async (options) => {
+
+ let visualselector_xpath_selectors = options.visualselector_xpath_selectors
+ let max_height = options.max_height
+
+ var scroll_y = 0;
+ try {
+ scroll_y = +document.documentElement.scrollTop || document.body.scrollTop
+ } catch (e) {
+ console.log(e);
+ }
+
+// Include the getXpath script directly, easier than fetching
+ function getxpath(e) {
+ var n = e;
+ if (n && n.id) return '//*[@id="' + n.id + '"]';
+ for (var o = []; n && Node.ELEMENT_NODE === n.nodeType;) {
+ for (var i = 0, r = !1, d = n.previousSibling; d;) d.nodeType !== Node.DOCUMENT_TYPE_NODE && d.nodeName === n.nodeName && i++, d = d.previousSibling;
+ for (d = n.nextSibling; d;) {
+ if (d.nodeName === n.nodeName) {
+ r = !0;
+ break
+ }
+ d = d.nextSibling
+ }
+ o.push((n.prefix ? n.prefix + ":" : "") + n.localName + (i || r ? "[" + (i + 1) + "]" : "")), n = n.parentNode
+ }
+ return o.length ? "/" + o.reverse().join("/") : ""
+ }
+
+ const findUpTag = (el) => {
+ let r = el
+ chained_css = [];
+ depth = 0;
+
+ // Strategy 1: If it's an input, with name, and there's only one, prefer that
+ if (el.name !== undefined && el.name.length) {
+ var proposed = el.tagName + "[name=\"" + CSS.escape(el.name) + "\"]";
+ var proposed_element = window.document.querySelectorAll(proposed);
+ if (proposed_element.length) {
+ if (proposed_element.length === 1) {
+ return proposed;
+ } else {
+ // Some sites change ID but name= stays the same, we can hit it if we know the index
+ // Find all the elements that match and work out the input[n]
+ var n = Array.from(proposed_element).indexOf(el);
+ // Return a Playwright selector for nthinput[name=zipcode]
+ return proposed + " >> nth=" + n;
+ }
+ }
+ }
+
+ // Strategy 2: Keep going up until we hit an ID tag, imagine it's like #list-widget div h4
+ while (r.parentNode) {
+ if (depth === 5) {
+ break;
+ }
+ if ('' !== r.id) {
+ chained_css.unshift("#" + CSS.escape(r.id));
+ final_selector = chained_css.join(' > ');
+ // Be sure theres only one, some sites have multiples of the same ID tag :-(
+ if (window.document.querySelectorAll(final_selector).length === 1) {
+ return final_selector;
+ }
+ return null;
+ } else {
+ chained_css.unshift(r.tagName.toLowerCase());
+ }
+ r = r.parentNode;
+ depth += 1;
+ }
+ return null;
+ }
+
+
+// @todo - if it's SVG or IMG, go into image diff mode
+
+ var size_pos = [];
+// after page fetch, inject this JS
+// build a map of all elements and their positions (maybe that only include text?)
+ var bbox;
+ console.log(`Scanning for "${visualselector_xpath_selectors}"`);
+
+ function collectVisibleElements(parent, visibleElements) {
+ if (!parent) return; // Base case: if parent is null or undefined, return
+
+
+ // Add the parent itself to the visible elements array if it's of the specified types
+ const tagName = parent.tagName.toLowerCase();
+ if (visualselector_xpath_selectors.split(',').includes(tagName)) {
+ visibleElements.push(parent);
+ }
+
+ // Iterate over the parent's children
+ const children = parent.children;
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ const computedStyle = window.getComputedStyle(child);
+
+ if (
+ child.nodeType === Node.ELEMENT_NODE &&
+ computedStyle.display !== 'none' &&
+ computedStyle.visibility !== 'hidden' &&
+ child.offsetWidth >= 0 &&
+ child.offsetHeight >= 0 &&
+ computedStyle.contentVisibility !== 'hidden'
+ ) {
+ // If the child is an element and is visible, recursively collect visible elements
+ collectVisibleElements(child, visibleElements);
+ }
+ }
+ }
+
+// Create an array to hold the visible elements
+ const visibleElementsArray = [];
+
+// Call collectVisibleElements with the starting parent element
+ collectVisibleElements(document.body, visibleElementsArray);
+
+
+ visibleElementsArray.forEach(function (element) {
+
+ bbox = element.getBoundingClientRect();
+
+ // Skip really small ones, and where width or height ==0
+ if (bbox['width'] * bbox['height'] < 10) {
+ return
+ }
+
+ // Don't include elements that are offset from canvas
+ if (bbox['top'] + scroll_y < 0 || bbox['left'] < 0) {
+ return
+ }
+
+ // @todo the getXpath kind of sucks, it doesnt know when there is for example just one ID sometimes
+ // it should not traverse when we know we can anchor off just an ID one level up etc..
+ // maybe, get current class or id, keep traversing up looking for only class or id until there is just one match
+
+ // 1st primitive - if it has class, try joining it all and select, if theres only one.. well thats us.
+ xpath_result = false;
+ try {
+ var d = findUpTag(element);
+ if (d) {
+ xpath_result = d;
+ }
+ } catch (e) {
+ console.log(e);
+ }
+ // You could swap it and default to getXpath and then try the smarter one
+ // default back to the less intelligent one
+ if (!xpath_result) {
+ try {
+ // I've seen on FB and eBay that this doesnt work
+ // ReferenceError: getXPath is not defined at eval (eval at evaluate (:152:29), :67:20) at UtilityScript.evaluate (:159:18) at UtilityScript. (:1:44)
+ xpath_result = getxpath(element);
+ } catch (e) {
+ console.log(e);
+ return
+ }
+ }
+
+ let label = "not-interesting" // A placeholder, the actual labels for training are done by hand for now
+
+ let text = element.textContent.trim().slice(0, 30).trim();
+ while (/\n{2,}|\t{2,}/.test(text)) {
+ text = text.replace(/\n{2,}/g, '\n').replace(/\t{2,}/g, '\t')
+ }
+
+ // Try to identify any possible currency amounts "Sale: 4000" or "Sale now 3000 Kc", can help with the training.
+ const hasDigitCurrency = (/\d/.test(text.slice(0, 6)) || /\d/.test(text.slice(-6))) && /([€£$¥₩₹]|USD|AUD|EUR|Kč|kr|SEK|,–)/.test(text);
+ const computedStyle = window.getComputedStyle(element);
+
+ if (Math.floor(bbox['top']) + scroll_y > max_height) {
+ return
+ }
+
+ size_pos.push({
+ xpath: xpath_result,
+ width: Math.round(bbox['width']),
+ height: Math.round(bbox['height']),
+ left: Math.floor(bbox['left']),
+ top: Math.floor(bbox['top']) + scroll_y,
+ // tagName used by Browser Steps
+ tagName: (element.tagName) ? element.tagName.toLowerCase() : '',
+ // tagtype used by Browser Steps
+ tagtype: (element.tagName.toLowerCase() === 'input' && element.type) ? element.type.toLowerCase() : '',
+ isClickable: computedStyle.cursor === "pointer",
+ // Used by the keras trainer
+ fontSize: computedStyle.getPropertyValue('font-size'),
+ fontWeight: computedStyle.getPropertyValue('font-weight'),
+ hasDigitCurrency: hasDigitCurrency,
+ label: label,
+ });
+
+ });
+
+
+// Inject the current one set in the include_filters, which may be a CSS rule
+// used for displaying the current one in VisualSelector, where its not one we generated.
+ if (include_filters.length) {
+ let results;
+ // Foreach filter, go and find it on the page and add it to the results so we can visualise it again
+ for (const f of include_filters) {
+ bbox = false;
+
+ if (!f.length) {
+ console.log("xpath_element_scraper: Empty filter, skipping");
+ continue;
+ }
+
+ try {
+ // is it xpath?
+ if (f.startsWith('/') || f.startsWith('xpath')) {
+ var qry_f = f.replace(/xpath(:|\d:)/, '')
+ console.log("[xpath] Scanning for included filter " + qry_f)
+ let xpathResult = document.evaluate(qry_f, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
+ results = [];
+ for (let i = 0; i < xpathResult.snapshotLength; i++) {
+ results.push(xpathResult.snapshotItem(i));
+ }
+ } else {
+ console.log("[css] Scanning for included filter " + f)
+ console.log("[css] Scanning for included filter " + f);
+ results = document.querySelectorAll(f);
+ }
+ } catch (e) {
+ // Maybe catch DOMException and alert?
+ console.log("xpath_element_scraper: Exception selecting element from filter " + f);
+ console.log(e);
+ }
+
+ if (results != null && results.length) {
+
+ // Iterate over the results
+ results.forEach(node => {
+ // Try to resolve //something/text() back to its /something so we can atleast get the bounding box
+ try {
+ if (typeof node.nodeName == 'string' && node.nodeName === '#text') {
+ node = node.parentElement
+ }
+ } catch (e) {
+ console.log(e)
+ console.log("xpath_element_scraper: #text resolver")
+ }
+
+ // #1231 - IN the case XPath attribute filter is applied, we will have to traverse up and find the element.
+ if (typeof node.getBoundingClientRect == 'function') {
+ bbox = node.getBoundingClientRect();
+ console.log("xpath_element_scraper: Got filter element, scroll from top was " + scroll_y)
+ } else {
+ try {
+ // Try and see we can find its ownerElement
+ bbox = node.ownerElement.getBoundingClientRect();
+ console.log("xpath_element_scraper: Got filter by ownerElement element, scroll from top was " + scroll_y)
+ } catch (e) {
+ console.log(e)
+ console.log("xpath_element_scraper: error looking up node.ownerElement")
+ }
+ }
+
+ if (bbox && bbox['width'] > 0 && bbox['height'] > 0) {
+ size_pos.push({
+ xpath: f,
+ width: parseInt(bbox['width']),
+ height: parseInt(bbox['height']),
+ left: parseInt(bbox['left']),
+ top: parseInt(bbox['top']) + scroll_y,
+ highlight_as_custom_filter: true
+ });
+ }
+ });
+ }
+ }
+ }
+
+// Sort the elements so we find the smallest one first, in other words, we find the smallest one matching in that area
+// so that we dont select the wrapping element by mistake and be unable to select what we want
+ size_pos.sort((a, b) => (a.width * a.height > b.width * b.height) ? 1 : -1)
+
+// browser_width required for proper scaling in the frontend
+ // Return as a string to save playwright for juggling thousands of objects
+ return JSON.stringify({'size_pos': size_pos, 'browser_width': window.innerWidth});
+}
+
diff --git a/changedetectionio/content_fetchers/screenshot_handler.py b/changedetectionio/content_fetchers/screenshot_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..170224bdf732109ccabb0d0c7f3f92c5719b1eee
--- /dev/null
+++ b/changedetectionio/content_fetchers/screenshot_handler.py
@@ -0,0 +1,73 @@
+# Pages with a vertical height longer than this will use the 'stitch together' method.
+
+# - Many GPUs have a max texture size of 16384x16384px (or lower on older devices).
+# - If a page is taller than ~8000–10000px, it risks exceeding GPU memory limits.
+# - This is especially important on headless Chromium, where Playwright may fail to allocate a massive full-page buffer.
+
+from loguru import logger
+
+from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, SCREENSHOT_DEFAULT_QUALITY
+
+
+def stitch_images_worker(pipe_conn, chunks_bytes, original_page_height, capture_height):
+ import os
+ import io
+ from PIL import Image, ImageDraw, ImageFont
+
+ try:
+
+ # Load images from byte chunks
+ images = [Image.open(io.BytesIO(b)) for b in chunks_bytes]
+ total_height = sum(im.height for im in images)
+ max_width = max(im.width for im in images)
+
+ # Create stitched image
+ stitched = Image.new('RGB', (max_width, total_height))
+ y_offset = 0
+ for im in images:
+ stitched.paste(im, (0, y_offset))
+ y_offset += im.height
+
+ # Draw caption on top (overlaid, not extending canvas)
+ draw = ImageDraw.Draw(stitched)
+
+ if original_page_height > capture_height:
+ caption_text = f"WARNING: Screenshot was {original_page_height}px but trimmed to {capture_height}px because it was too long"
+ padding = 10
+ font_size = 35
+ font_color = (255, 0, 0)
+ background_color = (255, 255, 255)
+
+
+ # Try to load a proper font
+ try:
+ font = ImageFont.truetype("arial.ttf", font_size)
+ except IOError:
+ font = ImageFont.load_default()
+
+ bbox = draw.textbbox((0, 0), caption_text, font=font)
+ text_width = bbox[2] - bbox[0]
+ text_height = bbox[3] - bbox[1]
+
+ # Draw white rectangle background behind text
+ rect_top = 0
+ rect_bottom = text_height + 2 * padding
+ draw.rectangle([(0, rect_top), (max_width, rect_bottom)], fill=background_color)
+
+ # Draw text centered horizontally, 10px padding from top of the rectangle
+ text_x = (max_width - text_width) // 2
+ text_y = padding
+ draw.text((text_x, text_y), caption_text, font=font, fill=font_color)
+
+ # Encode and send image
+ output = io.BytesIO()
+ stitched.save(output, format="JPEG", quality=int(os.getenv("SCREENSHOT_QUALITY", SCREENSHOT_DEFAULT_QUALITY)))
+ pipe_conn.send_bytes(output.getvalue())
+
+ stitched.close()
+ except Exception as e:
+ pipe_conn.send(f"error:{e}")
+ finally:
+ pipe_conn.close()
+
+
diff --git a/changedetectionio/content_fetchers/webdriver_selenium.py b/changedetectionio/content_fetchers/webdriver_selenium.py
new file mode 100644
index 0000000000000000000000000000000000000000..48897d7aa87cb6065f42fcf8a99469745ec7036d
--- /dev/null
+++ b/changedetectionio/content_fetchers/webdriver_selenium.py
@@ -0,0 +1,143 @@
+import os
+import time
+
+from loguru import logger
+from changedetectionio.content_fetchers.base import Fetcher
+
+class fetcher(Fetcher):
+ if os.getenv("WEBDRIVER_URL"):
+ fetcher_description = "WebDriver Chrome/Javascript via '{}'".format(os.getenv("WEBDRIVER_URL"))
+ else:
+ fetcher_description = "WebDriver Chrome/Javascript"
+
+ proxy = None
+ proxy_url = None
+
+ def __init__(self, proxy_override=None, custom_browser_connection_url=None):
+ super().__init__()
+ from urllib.parse import urlparse
+ from selenium.webdriver.common.proxy import Proxy
+
+ # .strip('"') is going to save someone a lot of time when they accidently wrap the env value
+ if not custom_browser_connection_url:
+ self.browser_connection_url = os.getenv("WEBDRIVER_URL", 'http://browser-chrome:4444/wd/hub').strip('"')
+ else:
+ self.browser_connection_is_custom = True
+ self.browser_connection_url = custom_browser_connection_url
+
+
+ ##### PROXY SETUP #####
+
+ proxy_sources = [
+ self.system_http_proxy,
+ self.system_https_proxy,
+ os.getenv('webdriver_proxySocks'),
+ os.getenv('webdriver_socksProxy'),
+ os.getenv('webdriver_proxyHttp'),
+ os.getenv('webdriver_httpProxy'),
+ os.getenv('webdriver_proxyHttps'),
+ os.getenv('webdriver_httpsProxy'),
+ os.getenv('webdriver_sslProxy'),
+ proxy_override, # last one should override
+ ]
+ # The built in selenium proxy handling is super unreliable!!! so we just grab which ever proxy setting we can find and throw it in --proxy-server=
+ for k in filter(None, proxy_sources):
+ if not k:
+ continue
+ self.proxy_url = k.strip()
+
+
+ async def run(self,
+ url,
+ timeout,
+ request_headers,
+ request_body,
+ request_method,
+ ignore_status_codes=False,
+ current_include_filters=None,
+ is_binary=False,
+ empty_pages_are_a_change=False):
+
+ import asyncio
+
+ # Wrap the entire selenium operation in a thread executor
+ def _run_sync():
+ from selenium.webdriver.chrome.options import Options as ChromeOptions
+ # request_body, request_method unused for now, until some magic in the future happens.
+
+ options = ChromeOptions()
+
+ # Load Chrome options from env
+ CHROME_OPTIONS = [
+ line.strip()
+ for line in os.getenv("CHROME_OPTIONS", "").strip().splitlines()
+ if line.strip()
+ ]
+
+ for opt in CHROME_OPTIONS:
+ options.add_argument(opt)
+
+ # 1. proxy_config /Proxy(proxy_config) selenium object is REALLY unreliable
+ # 2. selenium-wire cant be used because the websocket version conflicts with pypeteer-ng
+ # 3. selenium only allows ONE runner at a time by default!
+ # 4. driver must use quit() or it will continue to block/hold the selenium process!!
+
+ if self.proxy_url:
+ options.add_argument(f'--proxy-server={self.proxy_url}')
+
+ from selenium.webdriver.remote.remote_connection import RemoteConnection
+ from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver
+ driver = None
+ try:
+ # Create the RemoteConnection and set timeout (e.g., 30 seconds)
+ remote_connection = RemoteConnection(
+ self.browser_connection_url,
+ )
+ remote_connection.set_timeout(30) # seconds
+
+ # Now create the driver with the RemoteConnection
+ driver = RemoteWebDriver(
+ command_executor=remote_connection,
+ options=options
+ )
+
+ driver.set_page_load_timeout(int(os.getenv("WEBDRIVER_PAGELOAD_TIMEOUT", 45)))
+ except Exception as e:
+ if driver:
+ driver.quit()
+ raise e
+
+ try:
+ driver.get(url)
+
+ if not "--window-size" in os.getenv("CHROME_OPTIONS", ""):
+ driver.set_window_size(1280, 1024)
+
+ driver.implicitly_wait(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)))
+
+ if self.webdriver_js_execute_code is not None:
+ driver.execute_script(self.webdriver_js_execute_code)
+ # Selenium doesn't automatically wait for actions as good as Playwright, so wait again
+ driver.implicitly_wait(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)))
+
+ # @todo - how to check this? is it possible?
+ self.status_code = 200
+ # @todo somehow we should try to get this working for WebDriver
+ # raise EmptyReply(url=url, status_code=r.status_code)
+
+ # @todo - dom wait loaded?
+ import time
+ time.sleep(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay)
+ self.content = driver.page_source
+ self.headers = {}
+ self.screenshot = driver.get_screenshot_as_png()
+ except Exception as e:
+ driver.quit()
+ raise e
+
+ driver.quit()
+
+ # Run the selenium operations in a thread pool to avoid blocking the event loop
+ loop = asyncio.get_event_loop()
+ await loop.run_in_executor(None, _run_sync)
+
diff --git a/changedetectionio/custom_queue.py b/changedetectionio/custom_queue.py
new file mode 100644
index 0000000000000000000000000000000000000000..be571f17f015282b01a75e39cc1f08a6939f9740
--- /dev/null
+++ b/changedetectionio/custom_queue.py
@@ -0,0 +1,535 @@
+import queue
+import asyncio
+from blinker import signal
+from loguru import logger
+
+
+class NotificationQueue(queue.Queue):
+ """
+ Extended Queue that sends a 'notification_event' signal when notifications are added.
+
+ This class extends the standard Queue and adds a signal emission after a notification
+ is put into the queue. The signal includes the watch UUID if available.
+ """
+
+ def __init__(self, maxsize=0):
+ super().__init__(maxsize)
+ try:
+ self.notification_event_signal = signal('notification_event')
+ except Exception as e:
+ logger.critical(f"Exception creating notification_event signal: {e}")
+
+ def put(self, item, block=True, timeout=None):
+ # Call the parent's put method first
+ super().put(item, block, timeout)
+
+ # After putting the notification in the queue, emit signal with watch UUID
+ try:
+ if self.notification_event_signal and isinstance(item, dict):
+ watch_uuid = item.get('uuid')
+ if watch_uuid:
+ # Send the notification_event signal with the watch UUID
+ self.notification_event_signal.send(watch_uuid=watch_uuid)
+ logger.trace(f"NotificationQueue: Emitted notification_event signal for watch UUID {watch_uuid}")
+ else:
+ # Send signal without UUID for system notifications
+ self.notification_event_signal.send()
+ logger.trace("NotificationQueue: Emitted notification_event signal for system notification")
+ except Exception as e:
+ logger.error(f"Exception emitting notification_event signal: {e}")
+
+class SignalPriorityQueue(queue.PriorityQueue):
+ """
+ Extended PriorityQueue that sends a signal when items with a UUID are added.
+
+ This class extends the standard PriorityQueue and adds a signal emission
+ after an item is put into the queue. If the item contains a UUID, the signal
+ is sent with that UUID as a parameter.
+ """
+
+ def __init__(self, maxsize=0):
+ super().__init__(maxsize)
+ try:
+ self.queue_length_signal = signal('queue_length')
+ except Exception as e:
+ logger.critical(f"Exception: {e}")
+
+ def put(self, item, block=True, timeout=None):
+ # Call the parent's put method first
+ super().put(item, block, timeout)
+
+ # After putting the item in the queue, check if it has a UUID and emit signal
+ if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:
+ uuid = item.item['uuid']
+ # Get the signal and send it if it exists
+ watch_check_update = signal('watch_check_update')
+ if watch_check_update:
+ # Send the watch_uuid parameter
+ watch_check_update.send(watch_uuid=uuid)
+
+ # Send queue_length signal with current queue size
+ try:
+
+ if self.queue_length_signal:
+ self.queue_length_signal.send(length=self.qsize())
+ except Exception as e:
+ logger.critical(f"Exception: {e}")
+
+ def get(self, block=True, timeout=None):
+ # Call the parent's get method first
+ item = super().get(block, timeout)
+
+ # Send queue_length signal with current queue size
+ try:
+ if self.queue_length_signal:
+ self.queue_length_signal.send(length=self.qsize())
+ except Exception as e:
+ logger.critical(f"Exception: {e}")
+ return item
+
+ def get_uuid_position(self, target_uuid):
+ """
+ Find the position of a watch UUID in the priority queue.
+ Optimized for large queues - O(n) complexity instead of O(n log n).
+
+ Args:
+ target_uuid: The UUID to search for
+
+ Returns:
+ dict: Contains position info or None if not found
+ - position: 0-based position in queue (0 = next to be processed)
+ - total_items: total number of items in queue
+ - priority: the priority value of the found item
+ """
+ with self.mutex:
+ queue_list = list(self.queue)
+ total_items = len(queue_list)
+
+ if total_items == 0:
+ return {
+ 'position': None,
+ 'total_items': 0,
+ 'priority': None,
+ 'found': False
+ }
+
+ # Find the target item and its priority first - O(n)
+ target_item = None
+ target_priority = None
+
+ for item in queue_list:
+ if (hasattr(item, 'item') and
+ isinstance(item.item, dict) and
+ item.item.get('uuid') == target_uuid):
+ target_item = item
+ target_priority = item.priority
+ break
+
+ if target_item is None:
+ return {
+ 'position': None,
+ 'total_items': total_items,
+ 'priority': None,
+ 'found': False
+ }
+
+ # Count how many items have higher priority (lower numbers) - O(n)
+ position = 0
+ for item in queue_list:
+ # Items with lower priority numbers are processed first
+ if item.priority < target_priority:
+ position += 1
+ elif item.priority == target_priority and item != target_item:
+ # For same priority, count items that come before this one
+ # (Note: this is approximate since heap order isn't guaranteed for equal priorities)
+ position += 1
+
+ return {
+ 'position': position,
+ 'total_items': total_items,
+ 'priority': target_priority,
+ 'found': True
+ }
+
+ def get_all_queued_uuids(self, limit=None, offset=0):
+ """
+ Get UUIDs currently in the queue with their positions.
+ For large queues, use limit/offset for pagination.
+
+ Args:
+ limit: Maximum number of items to return (None = all)
+ offset: Number of items to skip (for pagination)
+
+ Returns:
+ dict: Contains items and metadata
+ - items: List of dicts with uuid, position, and priority
+ - total_items: Total number of items in queue
+ - returned_items: Number of items returned
+ - has_more: Whether there are more items after this page
+ """
+ with self.mutex:
+ queue_list = list(self.queue)
+ total_items = len(queue_list)
+
+ if total_items == 0:
+ return {
+ 'items': [],
+ 'total_items': 0,
+ 'returned_items': 0,
+ 'has_more': False
+ }
+
+ # For very large queues, warn about performance
+ if total_items > 1000 and limit is None:
+ logger.warning(f"Getting all {total_items} queued items without limit - this may be slow")
+
+ # Sort only if we need exact positions (expensive for large queues)
+ if limit is not None and limit <= 100:
+ # For small requests, we can afford to sort
+ queue_items = sorted(queue_list)
+ end_idx = min(offset + limit, len(queue_items)) if limit else len(queue_items)
+ items_to_process = queue_items[offset:end_idx]
+
+ result = []
+ for position, item in enumerate(items_to_process, start=offset):
+ if (hasattr(item, 'item') and
+ isinstance(item.item, dict) and
+ 'uuid' in item.item):
+
+ result.append({
+ 'uuid': item.item['uuid'],
+ 'position': position,
+ 'priority': item.priority
+ })
+
+ return {
+ 'items': result,
+ 'total_items': total_items,
+ 'returned_items': len(result),
+ 'has_more': (offset + len(result)) < total_items
+ }
+ else:
+ # For large requests, return items with approximate positions
+ # This is much faster O(n) instead of O(n log n)
+ result = []
+ processed = 0
+ skipped = 0
+
+ for item in queue_list:
+ if (hasattr(item, 'item') and
+ isinstance(item.item, dict) and
+ 'uuid' in item.item):
+
+ if skipped < offset:
+ skipped += 1
+ continue
+
+ if limit and processed >= limit:
+ break
+
+ # Approximate position based on priority comparison
+ approx_position = sum(1 for other in queue_list if other.priority < item.priority)
+
+ result.append({
+ 'uuid': item.item['uuid'],
+ 'position': approx_position, # Approximate
+ 'priority': item.priority
+ })
+ processed += 1
+
+ return {
+ 'items': result,
+ 'total_items': total_items,
+ 'returned_items': len(result),
+ 'has_more': (offset + len(result)) < total_items,
+ 'note': 'Positions are approximate for performance with large queues'
+ }
+
+ def get_queue_summary(self):
+ """
+ Get a quick summary of queue state without expensive operations.
+ O(n) complexity - fast even for large queues.
+
+ Returns:
+ dict: Queue summary statistics
+ """
+ with self.mutex:
+ queue_list = list(self.queue)
+ total_items = len(queue_list)
+
+ if total_items == 0:
+ return {
+ 'total_items': 0,
+ 'priority_breakdown': {},
+ 'immediate_items': 0,
+ 'clone_items': 0,
+ 'scheduled_items': 0
+ }
+
+ # Count items by priority type - O(n)
+ immediate_items = 0 # priority 1
+ clone_items = 0 # priority 5
+ scheduled_items = 0 # priority > 100 (timestamps)
+ priority_counts = {}
+
+ for item in queue_list:
+ priority = item.priority
+ priority_counts[priority] = priority_counts.get(priority, 0) + 1
+
+ if priority == 1:
+ immediate_items += 1
+ elif priority == 5:
+ clone_items += 1
+ elif priority > 100:
+ scheduled_items += 1
+
+ return {
+ 'total_items': total_items,
+ 'priority_breakdown': priority_counts,
+ 'immediate_items': immediate_items,
+ 'clone_items': clone_items,
+ 'scheduled_items': scheduled_items,
+ 'min_priority': min(priority_counts.keys()) if priority_counts else None,
+ 'max_priority': max(priority_counts.keys()) if priority_counts else None
+ }
+
+
+class AsyncSignalPriorityQueue(asyncio.PriorityQueue):
+ """
+ Async version of SignalPriorityQueue that sends signals when items are added/removed.
+
+ This class extends asyncio.PriorityQueue and maintains the same signal behavior
+ as the synchronous version for real-time UI updates.
+ """
+
+ def __init__(self, maxsize=0):
+ super().__init__(maxsize)
+ try:
+ self.queue_length_signal = signal('queue_length')
+ except Exception as e:
+ logger.critical(f"Exception: {e}")
+
+ async def put(self, item):
+ # Call the parent's put method first
+ await super().put(item)
+
+ # After putting the item in the queue, check if it has a UUID and emit signal
+ if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:
+ uuid = item.item['uuid']
+ # Get the signal and send it if it exists
+ watch_check_update = signal('watch_check_update')
+ if watch_check_update:
+ # Send the watch_uuid parameter
+ watch_check_update.send(watch_uuid=uuid)
+
+ # Send queue_length signal with current queue size
+ try:
+ if self.queue_length_signal:
+ self.queue_length_signal.send(length=self.qsize())
+ except Exception as e:
+ logger.critical(f"Exception: {e}")
+
+ async def get(self):
+ # Call the parent's get method first
+ item = await super().get()
+
+ # Send queue_length signal with current queue size
+ try:
+ if self.queue_length_signal:
+ self.queue_length_signal.send(length=self.qsize())
+ except Exception as e:
+ logger.critical(f"Exception: {e}")
+ return item
+
+ @property
+ def queue(self):
+ """
+ Provide compatibility with sync PriorityQueue.queue access
+ Returns the internal queue for template access
+ """
+ return self._queue if hasattr(self, '_queue') else []
+
+ def get_uuid_position(self, target_uuid):
+ """
+ Find the position of a watch UUID in the async priority queue.
+ Optimized for large queues - O(n) complexity instead of O(n log n).
+
+ Args:
+ target_uuid: The UUID to search for
+
+ Returns:
+ dict: Contains position info or None if not found
+ - position: 0-based position in queue (0 = next to be processed)
+ - total_items: total number of items in queue
+ - priority: the priority value of the found item
+ """
+ queue_list = list(self._queue)
+ total_items = len(queue_list)
+
+ if total_items == 0:
+ return {
+ 'position': None,
+ 'total_items': 0,
+ 'priority': None,
+ 'found': False
+ }
+
+ # Find the target item and its priority first - O(n)
+ target_item = None
+ target_priority = None
+
+ for item in queue_list:
+ if (hasattr(item, 'item') and
+ isinstance(item.item, dict) and
+ item.item.get('uuid') == target_uuid):
+ target_item = item
+ target_priority = item.priority
+ break
+
+ if target_item is None:
+ return {
+ 'position': None,
+ 'total_items': total_items,
+ 'priority': None,
+ 'found': False
+ }
+
+ # Count how many items have higher priority (lower numbers) - O(n)
+ position = 0
+ for item in queue_list:
+ if item.priority < target_priority:
+ position += 1
+ elif item.priority == target_priority and item != target_item:
+ position += 1
+
+ return {
+ 'position': position,
+ 'total_items': total_items,
+ 'priority': target_priority,
+ 'found': True
+ }
+
+ def get_all_queued_uuids(self, limit=None, offset=0):
+ """
+ Get UUIDs currently in the async queue with their positions.
+ For large queues, use limit/offset for pagination.
+
+ Args:
+ limit: Maximum number of items to return (None = all)
+ offset: Number of items to skip (for pagination)
+
+ Returns:
+ dict: Contains items and metadata (same structure as sync version)
+ """
+ queue_list = list(self._queue)
+ total_items = len(queue_list)
+
+ if total_items == 0:
+ return {
+ 'items': [],
+ 'total_items': 0,
+ 'returned_items': 0,
+ 'has_more': False
+ }
+
+ # Same logic as sync version but without mutex
+ if limit is not None and limit <= 100:
+ queue_items = sorted(queue_list)
+ end_idx = min(offset + limit, len(queue_items)) if limit else len(queue_items)
+ items_to_process = queue_items[offset:end_idx]
+
+ result = []
+ for position, item in enumerate(items_to_process, start=offset):
+ if (hasattr(item, 'item') and
+ isinstance(item.item, dict) and
+ 'uuid' in item.item):
+
+ result.append({
+ 'uuid': item.item['uuid'],
+ 'position': position,
+ 'priority': item.priority
+ })
+
+ return {
+ 'items': result,
+ 'total_items': total_items,
+ 'returned_items': len(result),
+ 'has_more': (offset + len(result)) < total_items
+ }
+ else:
+ # Fast approximate positions for large queues
+ result = []
+ processed = 0
+ skipped = 0
+
+ for item in queue_list:
+ if (hasattr(item, 'item') and
+ isinstance(item.item, dict) and
+ 'uuid' in item.item):
+
+ if skipped < offset:
+ skipped += 1
+ continue
+
+ if limit and processed >= limit:
+ break
+
+ approx_position = sum(1 for other in queue_list if other.priority < item.priority)
+
+ result.append({
+ 'uuid': item.item['uuid'],
+ 'position': approx_position,
+ 'priority': item.priority
+ })
+ processed += 1
+
+ return {
+ 'items': result,
+ 'total_items': total_items,
+ 'returned_items': len(result),
+ 'has_more': (offset + len(result)) < total_items,
+ 'note': 'Positions are approximate for performance with large queues'
+ }
+
+ def get_queue_summary(self):
+ """
+ Get a quick summary of async queue state.
+ O(n) complexity - fast even for large queues.
+ """
+ queue_list = list(self._queue)
+ total_items = len(queue_list)
+
+ if total_items == 0:
+ return {
+ 'total_items': 0,
+ 'priority_breakdown': {},
+ 'immediate_items': 0,
+ 'clone_items': 0,
+ 'scheduled_items': 0
+ }
+
+ immediate_items = 0
+ clone_items = 0
+ scheduled_items = 0
+ priority_counts = {}
+
+ for item in queue_list:
+ priority = item.priority
+ priority_counts[priority] = priority_counts.get(priority, 0) + 1
+
+ if priority == 1:
+ immediate_items += 1
+ elif priority == 5:
+ clone_items += 1
+ elif priority > 100:
+ scheduled_items += 1
+
+ return {
+ 'total_items': total_items,
+ 'priority_breakdown': priority_counts,
+ 'immediate_items': immediate_items,
+ 'clone_items': clone_items,
+ 'scheduled_items': scheduled_items,
+ 'min_priority': min(priority_counts.keys()) if priority_counts else None,
+ 'max_priority': max(priority_counts.keys()) if priority_counts else None
+ }
diff --git a/changedetectionio/diff.py b/changedetectionio/diff.py
new file mode 100644
index 0000000000000000000000000000000000000000..1fa9b60a4ba6938ffa7cf1a15cb283bee3d19171
--- /dev/null
+++ b/changedetectionio/diff.py
@@ -0,0 +1,113 @@
+import difflib
+from typing import List, Iterator, Union
+
+REMOVED_STYLE = "background-color: #fadad7; color: #b30000;"
+ADDED_STYLE = "background-color: #eaf2c2; color: #406619;"
+
+def same_slicer(lst: List[str], start: int, end: int) -> List[str]:
+ """Return a slice of the list, or a single element if start == end."""
+ return lst[start:end] if start != end else [lst[start]]
+
+def customSequenceMatcher(
+ before: List[str],
+ after: List[str],
+ include_equal: bool = False,
+ include_removed: bool = True,
+ include_added: bool = True,
+ include_replaced: bool = True,
+ include_change_type_prefix: bool = True,
+ html_colour: bool = False
+) -> Iterator[List[str]]:
+ """
+ Compare two sequences and yield differences based on specified parameters.
+
+ Args:
+ before (List[str]): Original sequence
+ after (List[str]): Modified sequence
+ include_equal (bool): Include unchanged parts
+ include_removed (bool): Include removed parts
+ include_added (bool): Include added parts
+ include_replaced (bool): Include replaced parts
+ include_change_type_prefix (bool): Add prefixes to indicate change types
+ html_colour (bool): Use HTML background colors for differences
+
+ Yields:
+ List[str]: Differences between sequences
+ """
+ cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in " \t", a=before, b=after)
+
+
+
+ for tag, alo, ahi, blo, bhi in cruncher.get_opcodes():
+ if include_equal and tag == 'equal':
+ yield before[alo:ahi]
+ elif include_removed and tag == 'delete':
+ if html_colour:
+ yield [f'{line} ' for line in same_slicer(before, alo, ahi)]
+ else:
+ yield [f"(removed) {line}" for line in same_slicer(before, alo, ahi)] if include_change_type_prefix else same_slicer(before, alo, ahi)
+ elif include_replaced and tag == 'replace':
+ if html_colour:
+ yield [f'{line} ' for line in same_slicer(before, alo, ahi)] + \
+ [f'{line} ' for line in same_slicer(after, blo, bhi)]
+ else:
+ yield [f"(changed) {line}" for line in same_slicer(before, alo, ahi)] + \
+ [f"(into) {line}" for line in same_slicer(after, blo, bhi)] if include_change_type_prefix else same_slicer(before, alo, ahi) + same_slicer(after, blo, bhi)
+ elif include_added and tag == 'insert':
+ if html_colour:
+ yield [f'{line} ' for line in same_slicer(after, blo, bhi)]
+ else:
+ yield [f"(added) {line}" for line in same_slicer(after, blo, bhi)] if include_change_type_prefix else same_slicer(after, blo, bhi)
+
+def render_diff(
+ previous_version_file_contents: str,
+ newest_version_file_contents: str,
+ include_equal: bool = False,
+ include_removed: bool = True,
+ include_added: bool = True,
+ include_replaced: bool = True,
+ line_feed_sep: str = "\n",
+ include_change_type_prefix: bool = True,
+ patch_format: bool = False,
+ html_colour: bool = False
+) -> str:
+ """
+ Render the difference between two file contents.
+
+ Args:
+ previous_version_file_contents (str): Original file contents
+ newest_version_file_contents (str): Modified file contents
+ include_equal (bool): Include unchanged parts
+ include_removed (bool): Include removed parts
+ include_added (bool): Include added parts
+ include_replaced (bool): Include replaced parts
+ line_feed_sep (str): Separator for lines in output
+ include_change_type_prefix (bool): Add prefixes to indicate change types
+ patch_format (bool): Use patch format for output
+ html_colour (bool): Use HTML background colors for differences
+
+ Returns:
+ str: Rendered difference
+ """
+ newest_lines = [line.rstrip() for line in newest_version_file_contents.splitlines()]
+ previous_lines = [line.rstrip() for line in previous_version_file_contents.splitlines()] if previous_version_file_contents else []
+
+ if patch_format:
+ patch = difflib.unified_diff(previous_lines, newest_lines)
+ return line_feed_sep.join(patch)
+
+ rendered_diff = customSequenceMatcher(
+ before=previous_lines,
+ after=newest_lines,
+ include_equal=include_equal,
+ include_removed=include_removed,
+ include_added=include_added,
+ include_replaced=include_replaced,
+ include_change_type_prefix=include_change_type_prefix,
+ html_colour=html_colour
+ )
+
+ def flatten(lst: List[Union[str, List[str]]]) -> str:
+ return line_feed_sep.join(flatten(x) if isinstance(x, list) else x for x in lst)
+
+ return flatten(rendered_diff)
\ No newline at end of file
diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py
new file mode 100644
index 0000000000000000000000000000000000000000..a254e8867e0fa48ae7c351aa4f5defe3394ab4e1
--- /dev/null
+++ b/changedetectionio/flask_app.py
@@ -0,0 +1,838 @@
+#!/usr/bin/env python3
+
+import flask_login
+import locale
+import os
+import queue
+import sys
+import threading
+import time
+import timeago
+from blinker import signal
+
+from changedetectionio.strtobool import strtobool
+from threading import Event
+from changedetectionio.custom_queue import SignalPriorityQueue, AsyncSignalPriorityQueue, NotificationQueue
+from changedetectionio import worker_handler
+
+from flask import (
+ Flask,
+ abort,
+ flash,
+ make_response,
+ redirect,
+ render_template,
+ request,
+ send_from_directory,
+ session,
+ url_for,
+)
+from flask_compress import Compress as FlaskCompress
+from flask_login import current_user
+from flask_restful import abort, Api
+from flask_cors import CORS
+
+# Create specific signals for application events
+# Make this a global singleton to avoid multiple signal objects
+watch_check_update = signal('watch_check_update', doc='Signal sent when a watch check is completed')
+from flask_wtf import CSRFProtect
+from loguru import logger
+
+from changedetectionio import __version__
+from changedetectionio import queuedWatchMetaData
+from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications
+from changedetectionio.api.Search import Search
+from .time_handler import is_within_schedule
+
+datastore = None
+
+# Local
+ticker_thread = None
+extra_stylesheets = []
+
+# Use async queue by default, keep sync for backward compatibility
+update_q = AsyncSignalPriorityQueue() if worker_handler.USE_ASYNC_WORKERS else SignalPriorityQueue()
+notification_q = NotificationQueue()
+MAX_QUEUE_SIZE = 2000
+
+app = Flask(__name__,
+ static_url_path="",
+ static_folder="static",
+ template_folder="templates")
+
+# Will be initialized in changedetection_app
+socketio_server = None
+
+# Enable CORS, especially useful for the Chrome extension to operate from anywhere
+CORS(app)
+
+# Super handy for compressing large BrowserSteps responses and others
+FlaskCompress(app)
+
+# Stop browser caching of assets
+app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
+app.config.exit = Event()
+
+app.config['NEW_VERSION_AVAILABLE'] = False
+
+if os.getenv('FLASK_SERVER_NAME'):
+ app.config['SERVER_NAME'] = os.getenv('FLASK_SERVER_NAME')
+
+#app.config["EXPLAIN_TEMPLATE_LOADING"] = True
+
+# Disables caching of the templates
+app.config['TEMPLATES_AUTO_RELOAD'] = True
+app.jinja_env.add_extension('jinja2.ext.loopcontrols')
+csrf = CSRFProtect()
+csrf.init_app(app)
+notification_debug_log=[]
+
+# Locale for correct presentation of prices etc
+default_locale = locale.getdefaultlocale()
+logger.info(f"System locale default is {default_locale}")
+try:
+ locale.setlocale(locale.LC_ALL, default_locale)
+except locale.Error:
+ logger.warning(f"Unable to set locale {default_locale}, locale is not installed maybe?")
+
+watch_api = Api(app, decorators=[csrf.exempt])
+
+def init_app_secret(datastore_path):
+ secret = ""
+
+ path = "{}/secret.txt".format(datastore_path)
+
+ try:
+ with open(path, "r") as f:
+ secret = f.read()
+
+ except FileNotFoundError:
+ import secrets
+ with open(path, "w") as f:
+ secret = secrets.token_hex(32)
+ f.write(secret)
+
+ return secret
+
+
+@app.template_global()
+def get_darkmode_state():
+ css_dark_mode = request.cookies.get('css_dark_mode', 'false')
+ return 'true' if css_dark_mode and strtobool(css_dark_mode) else 'false'
+
+@app.template_global()
+def get_css_version():
+ return __version__
+
+@app.template_global()
+def get_socketio_path():
+ """Generate the correct Socket.IO path prefix for the client"""
+ # If behind a proxy with a sub-path, we need to respect that path
+ prefix = ""
+ if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Prefix' in request.headers:
+ prefix = request.headers['X-Forwarded-Prefix']
+
+ # Socket.IO will be available at {prefix}/socket.io/
+ return prefix
+
+
+@app.template_filter('format_number_locale')
+def _jinja2_filter_format_number_locale(value: float) -> str:
+ "Formats for example 4000.10 to the local locale default of 4,000.10"
+ # Format the number with two decimal places (locale format string will return 6 decimal)
+ formatted_value = locale.format_string("%.2f", value, grouping=True)
+
+ return formatted_value
+
+@app.template_global('is_checking_now')
+def _watch_is_checking_now(watch_obj, format="%Y-%m-%d %H:%M:%S"):
+ return worker_handler.is_watch_running(watch_obj['uuid'])
+
+@app.template_global('get_watch_queue_position')
+def _get_watch_queue_position(watch_obj):
+ """Get the position of a watch in the queue"""
+ uuid = watch_obj['uuid']
+ return update_q.get_uuid_position(uuid)
+
+@app.template_global('get_current_worker_count')
+def _get_current_worker_count():
+ """Get the current number of operational workers"""
+ return worker_handler.get_worker_count()
+
+@app.template_global('get_worker_status_info')
+def _get_worker_status_info():
+ """Get detailed worker status information for display"""
+ status = worker_handler.get_worker_status()
+ running_uuids = worker_handler.get_running_uuids()
+
+ return {
+ 'count': status['worker_count'],
+ 'type': status['worker_type'],
+ 'active_workers': len(running_uuids),
+ 'processing_watches': running_uuids,
+ 'loop_running': status.get('async_loop_running', None)
+ }
+
+
+# We use the whole watch object from the store/JSON so we can see if there's some related status in terms of a thread
+# running or something similar.
+@app.template_filter('format_last_checked_time')
+def _jinja2_filter_datetime(watch_obj, format="%Y-%m-%d %H:%M:%S"):
+
+ if watch_obj['last_checked'] == 0:
+ return 'Not yet'
+
+ return timeago.format(int(watch_obj['last_checked']), time.time())
+
+@app.template_filter('format_timestamp_timeago')
+def _jinja2_filter_datetimestamp(timestamp, format="%Y-%m-%d %H:%M:%S"):
+ if not timestamp:
+ return 'Not yet'
+
+ return timeago.format(int(timestamp), time.time())
+
+
+@app.template_filter('pagination_slice')
+def _jinja2_filter_pagination_slice(arr, skip):
+ per_page = datastore.data['settings']['application'].get('pager_size', 50)
+ if per_page:
+ return arr[skip:skip + per_page]
+
+ return arr
+
+@app.template_filter('format_seconds_ago')
+def _jinja2_filter_seconds_precise(timestamp):
+ if timestamp == False:
+ return 'Not yet'
+
+ return format(int(time.time()-timestamp), ',d')
+
+# Import login_optionally_required from auth_decorator
+from changedetectionio.auth_decorator import login_optionally_required
+
+# When nobody is logged in Flask-Login's current_user is set to an AnonymousUser object.
+class User(flask_login.UserMixin):
+ id=None
+
+ def set_password(self, password):
+ return True
+ def get_user(self, email="defaultuser@changedetection.io"):
+ return self
+ def is_authenticated(self):
+ return True
+ def is_active(self):
+ return True
+ def is_anonymous(self):
+ return False
+ def get_id(self):
+ return str(self.id)
+
+ # Compare given password against JSON store or Env var
+ def check_password(self, password):
+ import base64
+ import hashlib
+
+ # Can be stored in env (for deployments) or in the general configs
+ raw_salt_pass = os.getenv("SALTED_PASS", False)
+
+ if not raw_salt_pass:
+ raw_salt_pass = datastore.data['settings']['application'].get('password')
+
+ raw_salt_pass = base64.b64decode(raw_salt_pass)
+ salt_from_storage = raw_salt_pass[:32] # 32 is the length of the salt
+
+ # Use the exact same setup you used to generate the key, but this time put in the password to check
+ new_key = hashlib.pbkdf2_hmac(
+ 'sha256',
+ password.encode('utf-8'), # Convert the password to bytes
+ salt_from_storage,
+ 100000
+ )
+ new_key = salt_from_storage + new_key
+
+ return new_key == raw_salt_pass
+
+ pass
+
+
+def changedetection_app(config=None, datastore_o=None):
+ logger.trace("TRACE log is enabled")
+
+ global datastore, socketio_server
+ datastore = datastore_o
+
+ # so far just for read-only via tests, but this will be moved eventually to be the main source
+ # (instead of the global var)
+ app.config['DATASTORE'] = datastore_o
+
+ # Store the signal in the app config to ensure it's accessible everywhere
+ app.config['watch_check_update_SIGNAL'] = watch_check_update
+
+ login_manager = flask_login.LoginManager(app)
+ login_manager.login_view = 'login'
+ app.secret_key = init_app_secret(config['datastore_path'])
+
+ # Set up a request hook to check authentication for all routes
+ @app.before_request
+ def check_authentication():
+ has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False)
+
+ if has_password_enabled and not flask_login.current_user.is_authenticated:
+ # Permitted
+ if request.endpoint and request.endpoint == 'static_content' and request.view_args:
+ # Handled by static_content handler
+ return None
+ # Permitted
+ elif request.endpoint and 'login' in request.endpoint:
+ return None
+ elif request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):
+ return None
+ elif request.method in flask_login.config.EXEMPT_METHODS:
+ return None
+ elif app.config.get('LOGIN_DISABLED'):
+ return None
+ # RSS access with token is allowed
+ elif request.endpoint and 'rss.feed' in request.endpoint:
+ return None
+ # Socket.IO routes - need separate handling
+ elif request.path.startswith('/socket.io/'):
+ return None
+ # API routes - use their own auth mechanism (@auth.check_token)
+ elif request.path.startswith('/api/'):
+ return None
+ else:
+ return login_manager.unauthorized()
+
+
+ watch_api.add_resource(WatchSingleHistory,
+ '/api/v1/watch//history/',
+ resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
+
+ watch_api.add_resource(WatchHistory,
+ '/api/v1/watch//history',
+ resource_class_kwargs={'datastore': datastore})
+
+ watch_api.add_resource(CreateWatch, '/api/v1/watch',
+ resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
+
+ watch_api.add_resource(Watch, '/api/v1/watch/',
+ resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
+
+ watch_api.add_resource(SystemInfo, '/api/v1/systeminfo',
+ resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
+
+ watch_api.add_resource(Import,
+ '/api/v1/import',
+ resource_class_kwargs={'datastore': datastore})
+
+ watch_api.add_resource(Tags, '/api/v1/tags',
+ resource_class_kwargs={'datastore': datastore})
+
+ watch_api.add_resource(Tag, '/api/v1/tag', '/api/v1/tag/',
+ resource_class_kwargs={'datastore': datastore})
+
+ watch_api.add_resource(Search, '/api/v1/search',
+ resource_class_kwargs={'datastore': datastore})
+
+ watch_api.add_resource(Notifications, '/api/v1/notifications',
+ resource_class_kwargs={'datastore': datastore})
+
+ @login_manager.user_loader
+ def user_loader(email):
+ user = User()
+ user.get_user(email)
+ return user
+
+ @login_manager.unauthorized_handler
+ def unauthorized_handler():
+ flash("You must be logged in, please log in.", 'error')
+ return redirect(url_for('login', next=url_for('watchlist.index')))
+
+ @app.route('/logout')
+ def logout():
+ flask_login.logout_user()
+ return redirect(url_for('watchlist.index'))
+
+ # https://github.com/pallets/flask/blob/93dd1709d05a1cf0e886df6223377bdab3b077fb/examples/tutorial/flaskr/__init__.py#L39
+ # You can divide up the stuff like this
+ @app.route('/login', methods=['GET', 'POST'])
+ def login():
+
+ if request.method == 'GET':
+ if flask_login.current_user.is_authenticated:
+ flash("Already logged in")
+ return redirect(url_for("watchlist.index"))
+
+ output = render_template("login.html")
+ return output
+
+ user = User()
+ user.id = "defaultuser@changedetection.io"
+
+ password = request.form.get('password')
+
+ if (user.check_password(password)):
+ flask_login.login_user(user, remember=True)
+
+ # For now there's nothing else interesting here other than the index/list page
+ # It's more reliable and safe to ignore the 'next' redirect
+ # When we used...
+ # next = request.args.get('next')
+ # return redirect(next or url_for('watchlist.index'))
+ # We would sometimes get login loop errors on sites hosted in sub-paths
+
+ # note for the future:
+ # if not is_safe_url(next):
+ # return flask.abort(400)
+ return redirect(url_for('watchlist.index'))
+
+ else:
+ flash('Incorrect password', 'error')
+
+ return redirect(url_for('login'))
+
+ @app.before_request
+ def before_request_handle_cookie_x_settings():
+ # Set the auth cookie path if we're running as X-settings/X-Forwarded-Prefix
+ if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Prefix' in request.headers:
+ app.config['REMEMBER_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']
+ app.config['SESSION_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']
+ return None
+
+ @app.route("/static//", methods=['GET'])
+ def static_content(group, filename):
+ from flask import make_response
+ import re
+ group = re.sub(r'[^\w.-]+', '', group.lower())
+ filename = re.sub(r'[^\w.-]+', '', filename.lower())
+
+ if group == 'screenshot':
+ # Could be sensitive, follow password requirements
+ if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:
+ if not datastore.data['settings']['application'].get('shared_diff_access'):
+ abort(403)
+
+ screenshot_filename = "last-screenshot.png" if not request.args.get('error_screenshot') else "last-error-screenshot.png"
+
+ # These files should be in our subdirectory
+ try:
+ # set nocache, set content-type
+ response = make_response(send_from_directory(os.path.join(datastore_o.datastore_path, filename), screenshot_filename))
+ response.headers['Content-type'] = 'image/png'
+ response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
+ response.headers['Pragma'] = 'no-cache'
+ response.headers['Expires'] = 0
+ return response
+
+ except FileNotFoundError:
+ abort(404)
+
+
+ if group == 'visual_selector_data':
+ # Could be sensitive, follow password requirements
+ if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:
+ abort(403)
+
+ # These files should be in our subdirectory
+ try:
+ # set nocache, set content-type,
+ # `filename` is actually directory UUID of the watch
+ watch_directory = str(os.path.join(datastore_o.datastore_path, filename))
+ response = None
+ if os.path.isfile(os.path.join(watch_directory, "elements.deflate")):
+ response = make_response(send_from_directory(watch_directory, "elements.deflate"))
+ response.headers['Content-Type'] = 'application/json'
+ response.headers['Content-Encoding'] = 'deflate'
+ else:
+ logger.error(f'Request elements.deflate at "{watch_directory}" but was not found.')
+ abort(404)
+
+ if response:
+ response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
+ response.headers['Pragma'] = 'no-cache'
+ response.headers['Expires'] = "0"
+
+ return response
+
+ except FileNotFoundError:
+ abort(404)
+
+ # These files should be in our subdirectory
+ try:
+ return send_from_directory(f"static/{group}", path=filename)
+ except FileNotFoundError:
+ abort(404)
+
+
+ import changedetectionio.blueprint.browser_steps as browser_steps
+ app.register_blueprint(browser_steps.construct_blueprint(datastore), url_prefix='/browser-steps')
+
+ from changedetectionio.blueprint.imports import construct_blueprint as construct_import_blueprint
+ app.register_blueprint(construct_import_blueprint(datastore, update_q, queuedWatchMetaData), url_prefix='/imports')
+
+ import changedetectionio.blueprint.price_data_follower as price_data_follower
+ app.register_blueprint(price_data_follower.construct_blueprint(datastore, update_q), url_prefix='/price_data_follower')
+
+ import changedetectionio.blueprint.tags as tags
+ app.register_blueprint(tags.construct_blueprint(datastore), url_prefix='/tags')
+
+ import changedetectionio.blueprint.check_proxies as check_proxies
+ app.register_blueprint(check_proxies.construct_blueprint(datastore=datastore), url_prefix='/check_proxy')
+
+ import changedetectionio.blueprint.backups as backups
+ app.register_blueprint(backups.construct_blueprint(datastore), url_prefix='/backups')
+
+ import changedetectionio.blueprint.settings as settings
+ app.register_blueprint(settings.construct_blueprint(datastore), url_prefix='/settings')
+
+ import changedetectionio.conditions.blueprint as conditions
+ app.register_blueprint(conditions.construct_blueprint(datastore), url_prefix='/conditions')
+
+ import changedetectionio.blueprint.rss.blueprint as rss
+ app.register_blueprint(rss.construct_blueprint(datastore), url_prefix='/rss')
+
+ # watchlist UI buttons etc
+ import changedetectionio.blueprint.ui as ui
+ app.register_blueprint(ui.construct_blueprint(datastore, update_q, worker_handler, queuedWatchMetaData, watch_check_update))
+
+ import changedetectionio.blueprint.watchlist as watchlist
+ app.register_blueprint(watchlist.construct_blueprint(datastore=datastore, update_q=update_q, queuedWatchMetaData=queuedWatchMetaData), url_prefix='')
+
+ # Initialize Socket.IO server conditionally based on settings
+ socket_io_enabled = datastore.data['settings']['application']['ui'].get('socket_io_enabled', True)
+ if socket_io_enabled:
+ from changedetectionio.realtime.socket_server import init_socketio
+ global socketio_server
+ socketio_server = init_socketio(app, datastore)
+ logger.info("Socket.IO server initialized")
+ else:
+ logger.info("Socket.IO server disabled via settings")
+ socketio_server = None
+
+ # Memory cleanup endpoint
+ @app.route('/gc-cleanup', methods=['GET'])
+ @login_optionally_required
+ def gc_cleanup():
+ from changedetectionio.gc_cleanup import memory_cleanup
+ from flask import jsonify
+
+ result = memory_cleanup(app)
+ return jsonify({"status": "success", "message": "Memory cleanup completed", "result": result})
+
+ # Worker health check endpoint
+ @app.route('/worker-health', methods=['GET'])
+ @login_optionally_required
+ def worker_health():
+ from flask import jsonify
+
+ expected_workers = int(os.getenv("FETCH_WORKERS", datastore.data['settings']['requests']['workers']))
+
+ # Get basic status
+ status = worker_handler.get_worker_status()
+
+ # Perform health check
+ health_result = worker_handler.check_worker_health(
+ expected_count=expected_workers,
+ update_q=update_q,
+ notification_q=notification_q,
+ app=app,
+ datastore=datastore
+ )
+
+ return jsonify({
+ "status": "success",
+ "worker_status": status,
+ "health_check": health_result,
+ "expected_workers": expected_workers
+ })
+
+ # Queue status endpoint
+ @app.route('/queue-status', methods=['GET'])
+ @login_optionally_required
+ def queue_status():
+ from flask import jsonify, request
+
+ # Get specific UUID position if requested
+ target_uuid = request.args.get('uuid')
+
+ if target_uuid:
+ position_info = update_q.get_uuid_position(target_uuid)
+ return jsonify({
+ "status": "success",
+ "uuid": target_uuid,
+ "queue_position": position_info
+ })
+ else:
+ # Get pagination parameters
+ limit = request.args.get('limit', type=int)
+ offset = request.args.get('offset', type=int, default=0)
+ summary_only = request.args.get('summary', type=bool, default=False)
+
+ if summary_only:
+ # Fast summary for large queues
+ summary = update_q.get_queue_summary()
+ return jsonify({
+ "status": "success",
+ "queue_summary": summary
+ })
+ else:
+ # Get queued items with pagination support
+ if limit is None:
+ # Default limit for large queues to prevent performance issues
+ queue_size = update_q.qsize()
+ if queue_size > 100:
+ limit = 50
+ logger.warning(f"Large queue ({queue_size} items) detected, limiting to {limit} items. Use ?limit=N for more.")
+
+ all_queued = update_q.get_all_queued_uuids(limit=limit, offset=offset)
+ return jsonify({
+ "status": "success",
+ "queue_size": update_q.qsize(),
+ "queued_data": all_queued
+ })
+
+ # Start the async workers during app initialization
+ # Can be overridden by ENV or use the default settings
+ n_workers = int(os.getenv("FETCH_WORKERS", datastore.data['settings']['requests']['workers']))
+ logger.info(f"Starting {n_workers} workers during app initialization")
+ worker_handler.start_workers(n_workers, update_q, notification_q, app, datastore)
+
+ # @todo handle ctrl break
+ ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
+ threading.Thread(target=notification_runner).start()
+
+ in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
+ # Check for new release version, but not when running in test/build or pytest
+ if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')) and not in_pytest:
+ threading.Thread(target=check_for_new_version).start()
+
+ # Return the Flask app - the Socket.IO will be attached to it but initialized separately
+ # This avoids circular dependencies
+ return app
+
+
+# Check for new version and anonymous stats
+def check_for_new_version():
+ import requests
+ import urllib3
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+ while not app.config.exit.is_set():
+ try:
+ r = requests.post("https://changedetection.io/check-ver.php",
+ data={'version': __version__,
+ 'app_guid': datastore.data['app_guid'],
+ 'watch_count': len(datastore.data['watching'])
+ },
+
+ verify=False)
+ except:
+ pass
+
+ try:
+ if "new_version" in r.text:
+ app.config['NEW_VERSION_AVAILABLE'] = True
+ except:
+ pass
+
+ # Check daily
+ app.config.exit.wait(86400)
+
+
+def notification_runner():
+ global notification_debug_log
+ from datetime import datetime
+ import json
+ with app.app_context():
+ while not app.config.exit.is_set():
+ try:
+ # At the moment only one thread runs (single runner)
+ n_object = notification_q.get(block=False)
+ except queue.Empty:
+ time.sleep(1)
+
+ else:
+
+ now = datetime.now()
+ sent_obj = None
+
+ try:
+ from changedetectionio.notification.handler import process_notification
+
+ # Fallback to system config if not set
+ if not n_object.get('notification_body') and datastore.data['settings']['application'].get('notification_body'):
+ n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')
+
+ if not n_object.get('notification_title') and datastore.data['settings']['application'].get('notification_title'):
+ n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title')
+
+ if not n_object.get('notification_format') and datastore.data['settings']['application'].get('notification_format'):
+ n_object['notification_format'] = datastore.data['settings']['application'].get('notification_format')
+ if n_object.get('notification_urls', {}):
+ sent_obj = process_notification(n_object, datastore)
+
+ except Exception as e:
+ logger.error(f"Watch URL: {n_object['watch_url']} Error {str(e)}")
+
+ # UUID wont be present when we submit a 'test' from the global settings
+ if 'uuid' in n_object:
+ datastore.update_watch(uuid=n_object['uuid'],
+ update_obj={'last_notification_error': "Notification error detected, goto notification log."})
+
+ log_lines = str(e).splitlines()
+ notification_debug_log += log_lines
+
+ with app.app_context():
+ app.config['watch_check_update_SIGNAL'].send(app_context=app, watch_uuid=n_object.get('uuid'))
+
+ # Process notifications
+ notification_debug_log+= ["{} - SENDING - {}".format(now.strftime("%Y/%m/%d %H:%M:%S,000"), json.dumps(sent_obj))]
+ # Trim the log length
+ notification_debug_log = notification_debug_log[-100:]
+
+
+
+# Threaded runner, look for new watches to feed into the Queue.
+def ticker_thread_check_time_launch_checks():
+ import random
+ proxy_last_called_time = {}
+ last_health_check = 0
+
+ recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
+ logger.debug(f"System env MINIMUM_SECONDS_RECHECK_TIME {recheck_time_minimum_seconds}")
+
+ # Workers are now started during app initialization, not here
+
+ while not app.config.exit.is_set():
+
+ # Periodic worker health check (every 60 seconds)
+ now = time.time()
+ if now - last_health_check > 60:
+ expected_workers = int(os.getenv("FETCH_WORKERS", datastore.data['settings']['requests']['workers']))
+ health_result = worker_handler.check_worker_health(
+ expected_count=expected_workers,
+ update_q=update_q,
+ notification_q=notification_q,
+ app=app,
+ datastore=datastore
+ )
+
+ if health_result['status'] != 'healthy':
+ logger.warning(f"Worker health check: {health_result['message']}")
+
+ last_health_check = now
+
+ # Get a list of watches by UUID that are currently fetching data
+ running_uuids = worker_handler.get_running_uuids()
+
+ # Re #232 - Deepcopy the data incase it changes while we're iterating through it all
+ watch_uuid_list = []
+ while True:
+ try:
+ # Get a list of watches sorted by last_checked, [1] because it gets passed a tuple
+ # This is so we examine the most over-due first
+ for k in sorted(datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked',0)):
+ watch_uuid_list.append(k[0])
+
+ except RuntimeError as e:
+ # RuntimeError: dictionary changed size during iteration
+ time.sleep(0.1)
+ watch_uuid_list = []
+ else:
+ break
+
+ # Re #438 - Don't place more watches in the queue to be checked if the queue is already large
+ while update_q.qsize() >= 2000:
+ logger.warning(f"Recheck watches queue size limit reached ({MAX_QUEUE_SIZE}), skipping adding more items")
+ time.sleep(3)
+
+
+ recheck_time_system_seconds = int(datastore.threshold_seconds)
+
+ # Check for watches outside of the time threshold to put in the thread queue.
+ for uuid in watch_uuid_list:
+ now = time.time()
+ watch = datastore.data['watching'].get(uuid)
+ if not watch:
+ logger.error(f"Watch: {uuid} no longer present.")
+ continue
+
+ # No need todo further processing if it's paused
+ if watch['paused']:
+ continue
+
+ # @todo - Maybe make this a hook?
+ # Time schedule limit - Decide between watch or global settings
+ if watch.get('time_between_check_use_default'):
+ time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})
+ logger.trace(f"{uuid} Time scheduler - Using system/global settings")
+ else:
+ time_schedule_limit = watch.get('time_schedule_limit')
+ logger.trace(f"{uuid} Time scheduler - Using watch settings (not global settings)")
+ tz_name = datastore.data['settings']['application'].get('timezone', 'UTC')
+
+ if time_schedule_limit and time_schedule_limit.get('enabled'):
+ try:
+ result = is_within_schedule(time_schedule_limit=time_schedule_limit,
+ default_tz=tz_name
+ )
+ if not result:
+ logger.trace(f"{uuid} Time scheduler - not within schedule skipping.")
+ continue
+ except Exception as e:
+ logger.error(
+ f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}")
+ return False
+ # If they supplied an individual entry minutes to threshold.
+ threshold = recheck_time_system_seconds if watch.get('time_between_check_use_default') else watch.threshold_seconds()
+
+ # #580 - Jitter plus/minus amount of time to make the check seem more random to the server
+ jitter = datastore.data['settings']['requests'].get('jitter_seconds', 0)
+ if jitter > 0:
+ if watch.jitter_seconds == 0:
+ watch.jitter_seconds = random.uniform(-abs(jitter), jitter)
+
+ seconds_since_last_recheck = now - watch['last_checked']
+
+ if seconds_since_last_recheck >= (threshold + watch.jitter_seconds) and seconds_since_last_recheck >= recheck_time_minimum_seconds:
+ if not uuid in running_uuids and uuid not in [q_uuid.item['uuid'] for q_uuid in update_q.queue]:
+
+ # Proxies can be set to have a limit on seconds between which they can be called
+ watch_proxy = datastore.get_preferred_proxy_for_watch(uuid=uuid)
+ if watch_proxy and watch_proxy in list(datastore.proxy_list.keys()):
+ # Proxy may also have some threshold minimum
+ proxy_list_reuse_time_minimum = int(datastore.proxy_list.get(watch_proxy, {}).get('reuse_time_minimum', 0))
+ if proxy_list_reuse_time_minimum:
+ proxy_last_used_time = proxy_last_called_time.get(watch_proxy, 0)
+ time_since_proxy_used = int(time.time() - proxy_last_used_time)
+ if time_since_proxy_used < proxy_list_reuse_time_minimum:
+ # Not enough time difference reached, skip this watch
+ logger.debug(f"> Skipped UUID {uuid} "
+ f"using proxy '{watch_proxy}', not "
+ f"enough time between proxy requests "
+ f"{time_since_proxy_used}s/{proxy_list_reuse_time_minimum}s")
+ continue
+ else:
+ # Record the last used time
+ proxy_last_called_time[watch_proxy] = int(time.time())
+
+ # Use Epoch time as priority, so we get a "sorted" PriorityQueue, but we can still push a priority 1 into it.
+ priority = int(time.time())
+ logger.debug(
+ f"> Queued watch UUID {uuid} "
+ f"last checked at {watch['last_checked']} "
+ f"queued at {now:0.2f} priority {priority} "
+ f"jitter {watch.jitter_seconds:0.2f}s, "
+ f"{now - watch['last_checked']:0.2f}s since last checked")
+
+ # Into the queue with you
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=priority, item={'uuid': uuid}))
+
+ # Reset for next time
+ watch.jitter_seconds = 0
+
+ # Wait before checking the list again - saves CPU
+ time.sleep(1)
+
+ # Should be low so we can break this out in testing
+ app.config.exit.wait(1)
diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..5d9dafa95aae51c7b3835da5da632d1a114f200f
--- /dev/null
+++ b/changedetectionio/forms.py
@@ -0,0 +1,794 @@
+import os
+import re
+from loguru import logger
+from wtforms.widgets.core import TimeInput
+
+from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES
+from changedetectionio.conditions.form import ConditionFormRow
+from changedetectionio.strtobool import strtobool
+
+from wtforms import (
+ BooleanField,
+ Form,
+ Field,
+ IntegerField,
+ RadioField,
+ SelectField,
+ StringField,
+ SubmitField,
+ TextAreaField,
+ fields,
+ validators,
+ widgets
+)
+from flask_wtf.file import FileField, FileAllowed
+from wtforms.fields import FieldList
+
+from wtforms.validators import ValidationError
+
+from validators.url import url as url_validator
+
+
+# default
+# each select 0:
+ self.encrypted_password = self.build_password(valuelist[0])
+ self.data = ""
+ else:
+ self.data = False
+
+class StringTagUUID(StringField):
+
+ # process_formdata(self, valuelist) handled manually in POST handler
+
+ # Is what is shown when field is rendered
+ def _value(self):
+ # Tag UUID to name, on submit it will convert it back (in the submit handler of init.py)
+ if self.data and type(self.data) is list:
+ tag_titles = []
+ for i in self.data:
+ tag = self.datastore.data['settings']['application']['tags'].get(i)
+ if tag:
+ tag_title = tag.get('title')
+ if tag_title:
+ tag_titles.append(tag_title)
+
+ return ', '.join(tag_titles)
+
+ if not self.data:
+ return ''
+
+ return 'error'
+
+class TimeDurationForm(Form):
+ hours = SelectField(choices=[(f"{i}", f"{i}") for i in range(0, 25)], default="24", validators=[validators.Optional()])
+ minutes = SelectField(choices=[(f"{i}", f"{i}") for i in range(0, 60)], default="00", validators=[validators.Optional()])
+
+class TimeStringField(Field):
+ """
+ A WTForms field for time inputs (HH:MM) that stores the value as a string.
+ """
+ widget = TimeInput() # Use the built-in time input widget
+
+ def _value(self):
+ """
+ Returns the value for rendering in the form.
+ """
+ return self.data if self.data is not None else ""
+
+ def process_formdata(self, valuelist):
+ """
+ Processes the raw input from the form and stores it as a string.
+ """
+ if valuelist:
+ time_str = valuelist[0]
+ # Simple validation for HH:MM format
+ if not time_str or len(time_str.split(":")) != 2:
+ raise ValidationError("Invalid time format. Use HH:MM.")
+ self.data = time_str
+
+
+class validateTimeZoneName(object):
+ """
+ Flask wtform validators wont work with basic auth
+ """
+
+ def __init__(self, message=None):
+ self.message = message
+
+ def __call__(self, form, field):
+ from zoneinfo import available_timezones
+ python_timezones = available_timezones()
+ if field.data and field.data not in python_timezones:
+ raise ValidationError("Not a valid timezone name")
+
+class ScheduleLimitDaySubForm(Form):
+ enabled = BooleanField("not set", default=True)
+ start_time = TimeStringField("Start At", default="00:00", validators=[validators.Optional()])
+ duration = FormField(TimeDurationForm, label="Run duration")
+
+class ScheduleLimitForm(Form):
+ enabled = BooleanField("Use time scheduler", default=False)
+ # Because the label for=""" doesnt line up/work with the actual checkbox
+ monday = FormField(ScheduleLimitDaySubForm, label="")
+ tuesday = FormField(ScheduleLimitDaySubForm, label="")
+ wednesday = FormField(ScheduleLimitDaySubForm, label="")
+ thursday = FormField(ScheduleLimitDaySubForm, label="")
+ friday = FormField(ScheduleLimitDaySubForm, label="")
+ saturday = FormField(ScheduleLimitDaySubForm, label="")
+ sunday = FormField(ScheduleLimitDaySubForm, label="")
+
+ timezone = StringField("Optional timezone to run in",
+ render_kw={"list": "timezones"},
+ validators=[validateTimeZoneName()]
+ )
+ def __init__(
+ self,
+ formdata=None,
+ obj=None,
+ prefix="",
+ data=None,
+ meta=None,
+ **kwargs,
+ ):
+ super().__init__(formdata, obj, prefix, data, meta, **kwargs)
+ self.monday.form.enabled.label.text="Monday"
+ self.tuesday.form.enabled.label.text = "Tuesday"
+ self.wednesday.form.enabled.label.text = "Wednesday"
+ self.thursday.form.enabled.label.text = "Thursday"
+ self.friday.form.enabled.label.text = "Friday"
+ self.saturday.form.enabled.label.text = "Saturday"
+ self.sunday.form.enabled.label.text = "Sunday"
+
+
+class TimeBetweenCheckForm(Form):
+ weeks = IntegerField('Weeks', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
+ days = IntegerField('Days', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
+ hours = IntegerField('Hours', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
+ minutes = IntegerField('Minutes', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
+ seconds = IntegerField('Seconds', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
+ # @todo add total seconds minimum validatior = minimum_seconds_recheck_time
+
+# Separated by key:value
+class StringDictKeyValue(StringField):
+ widget = widgets.TextArea()
+
+ def _value(self):
+ if self.data:
+ output = ''
+ for k, v in self.data.items():
+ output += f"{k}: {v}\r\n"
+ return output
+ else:
+ return ''
+
+ # incoming data processing + validation
+ def process_formdata(self, valuelist):
+ self.data = {}
+ errors = []
+ if valuelist:
+ # Remove empty strings (blank lines)
+ cleaned = [line.strip() for line in valuelist[0].split("\n") if line.strip()]
+ for idx, s in enumerate(cleaned, start=1):
+ if ':' not in s:
+ errors.append(f"Line {idx} is missing a ':' separator.")
+ continue
+ parts = s.split(':', 1)
+ key = parts[0].strip()
+ value = parts[1].strip()
+
+ if not key:
+ errors.append(f"Line {idx} has an empty key.")
+ if not value:
+ errors.append(f"Line {idx} has an empty value.")
+
+ self.data[key] = value
+
+ if errors:
+ raise ValidationError("Invalid input:\n" + "\n".join(errors))
+
+class ValidateContentFetcherIsReady(object):
+ """
+ Validates that anything that looks like a regex passes as a regex
+ """
+ def __init__(self, message=None):
+ self.message = message
+
+ def __call__(self, form, field):
+ return
+
+# AttributeError: module 'changedetectionio.content_fetcher' has no attribute 'extra_browser_unlocked<>ASDF213r123r'
+ # Better would be a radiohandler that keeps a reference to each class
+ # if field.data is not None and field.data != 'system':
+ # klass = getattr(content_fetcher, field.data)
+ # some_object = klass()
+ # try:
+ # ready = some_object.is_ready()
+ #
+ # except urllib3.exceptions.MaxRetryError as e:
+ # driver_url = some_object.command_executor
+ # message = field.gettext('Content fetcher \'%s\' did not respond.' % (field.data))
+ # message += ' ' + field.gettext(
+ # 'Be sure that the selenium/webdriver runner is running and accessible via network from this container/host.')
+ # message += ' ' + field.gettext('Did you follow the instructions in the wiki?')
+ # message += ' ' + field.gettext('WebDriver Host: %s' % (driver_url))
+ # message += 'Go here for more information '
+ # message += ' '+field.gettext('Content fetcher did not respond properly, unable to use it.\n %s' % (str(e)))
+ #
+ # raise ValidationError(message)
+ #
+ # except Exception as e:
+ # message = field.gettext('Content fetcher \'%s\' did not respond properly, unable to use it.\n %s')
+ # raise ValidationError(message % (field.data, e))
+
+
+class ValidateNotificationBodyAndTitleWhenURLisSet(object):
+ """
+ Validates that they entered something in both notification title+body when the URL is set
+ Due to https://github.com/dgtlmoon/changedetection.io/issues/360
+ """
+
+ def __init__(self, message=None):
+ self.message = message
+
+ def __call__(self, form, field):
+ if len(field.data):
+ if not len(form.notification_title.data) or not len(form.notification_body.data):
+ message = field.gettext('Notification Body and Title is required when a Notification URL is used')
+ raise ValidationError(message)
+
+class ValidateAppRiseServers(object):
+ """
+ Validates that each URL given is compatible with AppRise
+ """
+
+ def __init__(self, message=None):
+ self.message = message
+
+ def __call__(self, form, field):
+ import apprise
+ from .notification.apprise_plugin.assets import apprise_asset
+ from .notification.apprise_plugin.custom_handlers import apprise_http_custom_handler # noqa: F401
+
+ apobj = apprise.Apprise(asset=apprise_asset)
+
+ for server_url in field.data:
+ url = server_url.strip()
+ if url.startswith("#"):
+ continue
+
+ if not apobj.add(url):
+ message = field.gettext('\'%s\' is not a valid AppRise URL.' % (url))
+ raise ValidationError(message)
+
+class ValidateJinja2Template(object):
+ """
+ Validates that a {token} is from a valid set
+ """
+ def __call__(self, form, field):
+ from changedetectionio import notification
+
+ from jinja2 import BaseLoader, TemplateSyntaxError, UndefinedError
+ from jinja2.sandbox import ImmutableSandboxedEnvironment
+ from jinja2.meta import find_undeclared_variables
+ import jinja2.exceptions
+
+ # Might be a list of text, or might be just text (like from the apprise url list)
+ joined_data = ' '.join(map(str, field.data)) if isinstance(field.data, list) else f"{field.data}"
+
+ try:
+ jinja2_env = ImmutableSandboxedEnvironment(loader=BaseLoader)
+ jinja2_env.globals.update(notification.valid_tokens)
+ # Extra validation tokens provided on the form_class(... extra_tokens={}) setup
+ if hasattr(field, 'extra_notification_tokens'):
+ jinja2_env.globals.update(field.extra_notification_tokens)
+
+ jinja2_env.from_string(joined_data).render()
+ except TemplateSyntaxError as e:
+ raise ValidationError(f"This is not a valid Jinja2 template: {e}") from e
+ except UndefinedError as e:
+ raise ValidationError(f"A variable or function is not defined: {e}") from e
+ except jinja2.exceptions.SecurityError as e:
+ raise ValidationError(f"This is not a valid Jinja2 template: {e}") from e
+
+ ast = jinja2_env.parse(joined_data)
+ undefined = ", ".join(find_undeclared_variables(ast))
+ if undefined:
+ raise ValidationError(
+ f"The following tokens used in the notification are not valid: {undefined}"
+ )
+
+class validateURL(object):
+
+ """
+ Flask wtform validators wont work with basic auth
+ """
+
+ def __init__(self, message=None):
+ self.message = message
+
+ def __call__(self, form, field):
+ # This should raise a ValidationError() or not
+ validate_url(field.data)
+
+
+def validate_url(test_url):
+ # If hosts that only contain alphanumerics are allowed ("localhost" for example)
+ try:
+ url_validator(test_url, simple_host=allow_simplehost)
+ except validators.ValidationError:
+ #@todo check for xss
+ message = f"'{test_url}' is not a valid URL."
+ # This should be wtforms.validators.
+ raise ValidationError(message)
+
+ from .model.Watch import is_safe_url
+ if not is_safe_url(test_url):
+ # This should be wtforms.validators.
+ raise ValidationError('Watch protocol is not permitted by SAFE_PROTOCOL_REGEX or incorrect URL format')
+
+class ValidateListRegex(object):
+ """
+ Validates that anything that looks like a regex passes as a regex
+ """
+ def __init__(self, message=None):
+ self.message = message
+
+ def __call__(self, form, field):
+
+ for line in field.data:
+ if re.search(html_tools.PERL_STYLE_REGEX, line, re.IGNORECASE):
+ try:
+ regex = html_tools.perl_style_slash_enclosed_regex_to_options(line)
+ re.compile(regex)
+ except re.error:
+ message = field.gettext('RegEx \'%s\' is not a valid regular expression.')
+ raise ValidationError(message % (line))
+
+class ValidateCSSJSONXPATHInput(object):
+ """
+ Filter validation
+ @todo CSS validator ;)
+ """
+
+ def __init__(self, message=None, allow_xpath=True, allow_json=True):
+ self.message = message
+ self.allow_xpath = allow_xpath
+ self.allow_json = allow_json
+
+ def __call__(self, form, field):
+
+ if isinstance(field.data, str):
+ data = [field.data]
+ else:
+ data = field.data
+
+ for line in data:
+ # Nothing to see here
+ if not len(line.strip()):
+ return
+
+ # Does it look like XPath?
+ if line.strip()[0] == '/' or line.strip().startswith('xpath:'):
+ if not self.allow_xpath:
+ raise ValidationError("XPath not permitted in this field!")
+ from lxml import etree, html
+ import elementpath
+ # xpath 2.0-3.1
+ from elementpath.xpath3 import XPath3Parser
+ tree = html.fromstring("")
+ line = line.replace('xpath:', '')
+
+ try:
+ elementpath.select(tree, line.strip(), parser=XPath3Parser)
+ except elementpath.ElementPathError as e:
+ message = field.gettext('\'%s\' is not a valid XPath expression. (%s)')
+ raise ValidationError(message % (line, str(e)))
+ except:
+ raise ValidationError("A system-error occurred when validating your XPath expression")
+
+ if line.strip().startswith('xpath1:'):
+ if not self.allow_xpath:
+ raise ValidationError("XPath not permitted in this field!")
+ from lxml import etree, html
+ tree = html.fromstring("")
+ line = re.sub(r'^xpath1:', '', line)
+
+ try:
+ tree.xpath(line.strip())
+ except etree.XPathEvalError as e:
+ message = field.gettext('\'%s\' is not a valid XPath expression. (%s)')
+ raise ValidationError(message % (line, str(e)))
+ except:
+ raise ValidationError("A system-error occurred when validating your XPath expression")
+
+ if 'json:' in line:
+ if not self.allow_json:
+ raise ValidationError("JSONPath not permitted in this field!")
+
+ from jsonpath_ng.exceptions import (
+ JsonPathLexerError,
+ JsonPathParserError,
+ )
+ from jsonpath_ng.ext import parse
+
+ input = line.replace('json:', '')
+
+ try:
+ parse(input)
+ except (JsonPathParserError, JsonPathLexerError) as e:
+ message = field.gettext('\'%s\' is not a valid JSONPath expression. (%s)')
+ raise ValidationError(message % (input, str(e)))
+ except:
+ raise ValidationError("A system-error occurred when validating your JSONPath expression")
+
+ # Re #265 - maybe in the future fetch the page and offer a
+ # warning/notice that its possible the rule doesnt yet match anything?
+ if not self.allow_json:
+ raise ValidationError("jq not permitted in this field!")
+
+ if 'jq:' in line:
+ try:
+ import jq
+ except ModuleNotFoundError:
+ # `jq` requires full compilation in windows and so isn't generally available
+ raise ValidationError("jq not support not found")
+
+ input = line.replace('jq:', '')
+
+ try:
+ jq.compile(input)
+ except (ValueError) as e:
+ message = field.gettext('\'%s\' is not a valid jq expression. (%s)')
+ raise ValidationError(message % (input, str(e)))
+ except:
+ raise ValidationError("A system-error occurred when validating your jq expression")
+
+class quickWatchForm(Form):
+ from . import processors
+
+ url = fields.URLField('URL', validators=[validateURL()])
+ tags = StringTagUUID('Group tag', [validators.Optional()])
+ watch_submit_button = SubmitField('Watch', render_kw={"class": "pure-button pure-button-primary"})
+ processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff")
+ edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"})
+
+
+
+# Common to a single watch and the global settings
+class commonSettingsForm(Form):
+ from . import processors
+
+ def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs):
+ super().__init__(formdata, obj, prefix, data, meta, **kwargs)
+ self.notification_body.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
+ self.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
+ self.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
+
+ extract_title_as_title = BooleanField('Extract from document and use as watch title', default=False)
+ fetch_backend = RadioField(u'Fetch Method', choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
+ notification_body = TextAreaField('Notification Body', default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()])
+ notification_format = SelectField('Notification format', choices=valid_notification_formats.keys())
+ notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])
+ notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()])
+ processor = RadioField( label=u"Processor - What do you want to achieve?", choices=processors.available_processors(), default="text_json_diff")
+ timezone = StringField("Timezone for watch schedule", render_kw={"list": "timezones"}, validators=[validateTimeZoneName()])
+ webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")])
+
+
+class importForm(Form):
+ from . import processors
+ processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff")
+ urls = TextAreaField('URLs')
+ xlsx_file = FileField('Upload .xlsx file', validators=[FileAllowed(['xlsx'], 'Must be .xlsx file!')])
+ file_mapping = SelectField('File mapping', [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')})
+
+class SingleBrowserStep(Form):
+
+ operation = SelectField('Operation', [validators.Optional()], choices=browser_step_ui_config.keys())
+
+ # maybe better to set some blob.. just return the first that matches json_filter
+ # As a last resort, try to parse the whole
+ soup = BeautifulSoup(content, 'html.parser')
+
+ if ensure_is_ldjson_info_type:
+ bs_result = soup.find_all('script', {"type": "application/ld+json"})
+ else:
+ bs_result = soup.find_all('script')
+ bs_result += soup.find_all('body')
+
+ bs_jsons = []
+ for result in bs_result:
+ # Skip empty tags, and things that dont even look like JSON
+ if not result.text or '{' not in result.text:
+ continue
+ try:
+ json_data = json.loads(result.text)
+ bs_jsons.append(json_data)
+ except json.JSONDecodeError:
+ # Skip objects which cannot be parsed
+ continue
+
+ if not bs_jsons:
+ raise JSONNotFound("No parsable JSON found in this document")
+
+ for json_data in bs_jsons:
+ stripped_text_from_html = _parse_json(json_data, json_filter)
+
+ if ensure_is_ldjson_info_type:
+ # Could sometimes be list, string or something else random
+ if isinstance(json_data, dict):
+ # If it has LD JSON 'key' @type, and @type is 'product', and something was found for the search
+ # (Some sites have multiple of the same ld+json @type='product', but some have the review part, some have the 'price' part)
+ # @type could also be a list although non-standard ("@type": ["Product", "SubType"],)
+ # LD_JSON auto-extract also requires some content PLUS the ldjson to be present
+ # 1833 - could be either str or dict, should not be anything else
+
+ t = json_data.get('@type')
+ if t and stripped_text_from_html:
+
+ if isinstance(t, str) and t.lower() == ensure_is_ldjson_info_type.lower():
+ break
+ # The non-standard part, some have a list
+ elif isinstance(t, list):
+ if ensure_is_ldjson_info_type.lower() in [x.lower().strip() for x in t]:
+ break
+
+ elif stripped_text_from_html:
+ break
+
+ if not stripped_text_from_html:
+ # Re 265 - Just return an empty string when filter not found
+ return ''
+
+ return stripped_text_from_html
+
+# Mode - "content" return the content without the matches (default)
+# - "line numbers" return a list of line numbers that match (int list)
+#
+# wordlist - list of regex's (str) or words (str)
+# Preserves all linefeeds and other whitespacing, its not the job of this to remove that
+def strip_ignore_text(content, wordlist, mode="content"):
+ ignore_text = []
+ ignore_regex = []
+ ignore_regex_multiline = []
+ ignored_lines = []
+
+ for k in wordlist:
+ # Is it a regex?
+ res = re.search(PERL_STYLE_REGEX, k, re.IGNORECASE)
+ if res:
+ res = re.compile(perl_style_slash_enclosed_regex_to_options(k))
+ if res.flags & re.DOTALL or res.flags & re.MULTILINE:
+ ignore_regex_multiline.append(res)
+ else:
+ ignore_regex.append(res)
+ else:
+ ignore_text.append(k.strip())
+
+ for r in ignore_regex_multiline:
+ for match in r.finditer(content):
+ content_lines = content[:match.end()].splitlines(keepends=True)
+ match_lines = content[match.start():match.end()].splitlines(keepends=True)
+
+ end_line = len(content_lines)
+ start_line = end_line - len(match_lines)
+
+ if end_line - start_line <= 1:
+ # Match is empty or in the middle of the line
+ ignored_lines.append(start_line)
+ else:
+ for i in range(start_line, end_line):
+ ignored_lines.append(i)
+
+ line_index = 0
+ lines = content.splitlines(keepends=True)
+ for line in lines:
+ # Always ignore blank lines in this mode. (when this function gets called)
+ got_match = False
+ for l in ignore_text:
+ if l.lower() in line.lower():
+ got_match = True
+
+ if not got_match:
+ for r in ignore_regex:
+ if r.search(line):
+ got_match = True
+
+ if got_match:
+ ignored_lines.append(line_index)
+
+ line_index += 1
+
+ ignored_lines = set([i for i in ignored_lines if i >= 0 and i < len(lines)])
+
+ # Used for finding out what to highlight
+ if mode == "line numbers":
+ return [i + 1 for i in ignored_lines]
+
+ output_lines = set(range(len(lines))) - ignored_lines
+ return ''.join([lines[i] for i in output_lines])
+
+def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str:
+ from xml.sax.saxutils import escape as xml_escape
+ pattern = ')\s*)*)\]\]>'
+ def repl(m):
+ text = m.group(1)
+ return xml_escape(html_to_text(html_content=text)).strip()
+
+ return re.sub(pattern, repl, html_content)
+
+
+# NOTE!! ANYTHING LIBXML, HTML5LIB ETC WILL CAUSE SOME SMALL MEMORY LEAK IN THE LOCAL "LIB" IMPLEMENTATION OUTSIDE PYTHON
+
+
+def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False, timeout=10) -> str:
+ from inscriptis import get_text
+ from inscriptis.model.config import ParserConfig
+
+ if render_anchor_tag_content:
+ parser_config = ParserConfig(
+ annotation_rules={"a": ["hyperlink"]},
+ display_links=True
+ )
+ else:
+ parser_config = None
+
+ if is_rss:
+ html_content = re.sub(r'])', r'', r' ', html_content)
+
+ text_content = get_text(html_content, config=parser_config)
+ return text_content
+
+# Does LD+JSON exist with a @type=='product' and a .price set anywhere?
+def has_ldjson_product_info(content):
+ try:
+ # Better than .lower() which can use a lot of ram
+ if (re.search(r'application/ld\+json', content, re.IGNORECASE) and
+ re.search(r'"price"', content, re.IGNORECASE) and
+ re.search(r'"pricecurrency"', content, re.IGNORECASE)):
+ return True
+
+# On some pages this is really terribly expensive when they dont really need it
+# (For example you never want price monitoring, but this runs on every watch to suggest it)
+# for filter in LD_JSON_PRODUCT_OFFER_SELECTORS:
+# pricing_data += extract_json_as_string(content=content,
+# json_filter=filter,
+# ensure_is_ldjson_info_type="product")
+ except Exception as e:
+ # OK too
+ return False
+
+ return False
+
+
+
+def workarounds_for_obfuscations(content):
+ """
+ Some sites are using sneaky tactics to make prices and other information un-renderable by Inscriptis
+ This could go into its own Pip package in the future, for faster updates
+ """
+
+ # HomeDepot.com style $90.74
+ # https://github.com/weblyzard/inscriptis/issues/45
+ if not content:
+ return content
+
+ content = re.sub('', '', content)
+
+ return content
+
+
+def get_triggered_text(content, trigger_text):
+ triggered_text = []
+ result = strip_ignore_text(content=content,
+ wordlist=trigger_text,
+ mode="line numbers")
+
+ i = 1
+ for p in content.splitlines():
+ if i in result:
+ triggered_text.append(p)
+ i += 1
+
+ return triggered_text
diff --git a/changedetectionio/model/App.py b/changedetectionio/model/App.py
new file mode 100644
index 0000000000000000000000000000000000000000..8de5b0aa3be5eb68193f4227659bfaf1cd2cb453
--- /dev/null
+++ b/changedetectionio/model/App.py
@@ -0,0 +1,85 @@
+from os import getenv
+
+from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES
+
+from changedetectionio.notification import (
+ default_notification_body,
+ default_notification_format,
+ default_notification_title,
+)
+
+# Equal to or greater than this number of FilterNotFoundInResponse exceptions will trigger a filter-not-found notification
+_FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT = 6
+DEFAULT_SETTINGS_HEADERS_USERAGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36'
+
+
+
+class model(dict):
+ base_config = {
+ 'note': "Hello! If you change this file manually, please be sure to restart your changedetection.io instance!",
+ 'watching': {},
+ 'settings': {
+ 'headers': {
+ },
+ 'requests': {
+ 'extra_proxies': [], # Configurable extra proxies via the UI
+ 'extra_browsers': [], # Configurable extra proxies via the UI
+ 'jitter_seconds': 0,
+ 'proxy': None, # Preferred proxy connection
+ 'time_between_check': {'weeks': None, 'days': None, 'hours': 3, 'minutes': None, 'seconds': None},
+ 'timeout': int(getenv("DEFAULT_SETTINGS_REQUESTS_TIMEOUT", "45")), # Default 45 seconds
+ 'workers': int(getenv("DEFAULT_SETTINGS_REQUESTS_WORKERS", "10")), # Number of threads, lower is better for slow connections
+ 'default_ua': {
+ 'html_requests': getenv("DEFAULT_SETTINGS_HEADERS_USERAGENT", DEFAULT_SETTINGS_HEADERS_USERAGENT),
+ 'html_webdriver': None,
+ }
+ },
+ 'application': {
+ # Custom notification content
+ 'api_access_token_enabled': True,
+ 'base_url' : None,
+ 'empty_pages_are_a_change': False,
+ 'extract_title_as_title': False,
+ 'fetch_backend': getenv("DEFAULT_FETCH_BACKEND", "html_requests"),
+ 'filter_failure_notification_threshold_attempts': _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT,
+ 'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum
+ 'global_subtractive_selectors': [],
+ 'ignore_whitespace': True,
+ 'notification_body': default_notification_body,
+ 'notification_format': default_notification_format,
+ 'notification_title': default_notification_title,
+ 'notification_urls': [], # Apprise URL list
+ 'pager_size': 50,
+ 'password': False,
+ 'render_anchor_tag_content': False,
+ 'rss_access_token': None,
+ 'rss_content_format': RSS_FORMAT_TYPES[0][0],
+ 'rss_hide_muted_watches': True,
+ 'schema_version' : 0,
+ 'shared_diff_access': False,
+ 'webdriver_delay': None , # Extra delay in seconds before extracting text
+ 'tags': {}, #@todo use Tag.model initialisers
+ 'timezone': None, # Default IANA timezone name
+ 'ui': {
+ 'open_diff_in_new_tab': True,
+ 'socket_io_enabled': True
+ },
+ }
+ }
+ }
+
+ def __init__(self, *arg, **kw):
+ super(model, self).__init__(*arg, **kw)
+ self.update(self.base_config)
+
+
+def parse_headers_from_text_file(filepath):
+ headers = {}
+ with open(filepath, 'r') as f:
+ for l in f.readlines():
+ l = l.strip()
+ if not l.startswith('#') and ':' in l:
+ (k, v) = l.split(':', 1) # Split only on the first colon
+ headers[k.strip()] = v.strip()
+
+ return headers
\ No newline at end of file
diff --git a/changedetectionio/model/Tag.py b/changedetectionio/model/Tag.py
new file mode 100644
index 0000000000000000000000000000000000000000..6dca480cddee30c75612ab3ec10344c3fc0fa399
--- /dev/null
+++ b/changedetectionio/model/Tag.py
@@ -0,0 +1,14 @@
+
+from changedetectionio.model import watch_base
+
+
+class model(watch_base):
+
+ def __init__(self, *arg, **kw):
+ super(model, self).__init__(*arg, **kw)
+
+ self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')
+
+ if kw.get('default'):
+ self.update(kw['default'])
+ del kw['default']
diff --git a/changedetectionio/model/Watch.py b/changedetectionio/model/Watch.py
new file mode 100644
index 0000000000000000000000000000000000000000..c2a8b9f576a0327c7fe0134635809f1faff88f91
--- /dev/null
+++ b/changedetectionio/model/Watch.py
@@ -0,0 +1,702 @@
+from blinker import signal
+
+from changedetectionio.strtobool import strtobool
+from changedetectionio.safe_jinja import render as jinja_render
+from . import watch_base
+import os
+import re
+from pathlib import Path
+from loguru import logger
+
+from ..html_tools import TRANSLATE_WHITESPACE_TABLE
+
+# Allowable protocols, protects against javascript: etc
+# file:// is further checked by ALLOW_FILE_URI
+SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):'
+
+minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
+mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
+
+
+def is_safe_url(test_url):
+ # See https://github.com/dgtlmoon/changedetection.io/issues/1358
+
+ # Remove 'source:' prefix so we dont get 'source:javascript:' etc
+ # 'source:' is a valid way to tell us to return the source
+
+ r = re.compile(re.escape('source:'), re.IGNORECASE)
+ test_url = r.sub('', test_url)
+
+ pattern = re.compile(os.getenv('SAFE_PROTOCOL_REGEX', SAFE_PROTOCOL_REGEX), re.IGNORECASE)
+ if not pattern.match(test_url.strip()):
+ return False
+
+ return True
+
+
+class model(watch_base):
+ __newest_history_key = None
+ __history_n = 0
+ jitter_seconds = 0
+
+ def __init__(self, *arg, **kw):
+ self.__datastore_path = kw.get('datastore_path')
+ if kw.get('datastore_path'):
+ del kw['datastore_path']
+
+ super(model, self).__init__(*arg, **kw)
+ if kw.get('default'):
+ self.update(kw['default'])
+ del kw['default']
+
+ if self.get('default'):
+ del self['default']
+
+ # Be sure the cached timestamp is ready
+ bump = self.history
+
+ @property
+ def viewed(self):
+ # Don't return viewed when last_viewed is 0 and newest_key is 0
+ if int(self['last_viewed']) and int(self['last_viewed']) >= int(self.newest_history_key) :
+ return True
+
+ return False
+
+ @property
+ def has_unviewed(self):
+ return int(self.newest_history_key) > int(self['last_viewed']) and self.__history_n >= 2
+
+ def ensure_data_dir_exists(self):
+ if not os.path.isdir(self.watch_data_dir):
+ logger.debug(f"> Creating data dir {self.watch_data_dir}")
+ os.mkdir(self.watch_data_dir)
+
+ @property
+ def link(self):
+
+ url = self.get('url', '')
+ if not is_safe_url(url):
+ return 'DISABLED'
+
+ ready_url = url
+ if '{%' in url or '{{' in url:
+ # Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/
+ try:
+ ready_url = jinja_render(template_str=url)
+ except Exception as e:
+ logger.critical(f"Invalid URL template for: '{url}' - {str(e)}")
+ from flask import (
+ flash, Markup, url_for
+ )
+ message = Markup('The URL {} is invalid and cannot be used, click to edit '.format(
+ url_for('ui.ui_edit.edit_page', uuid=self.get('uuid')), self.get('url', '')))
+ flash(message, 'error')
+ return ''
+
+ if ready_url.startswith('source:'):
+ ready_url=ready_url.replace('source:', '')
+
+ # Also double check it after any Jinja2 formatting just incase
+ if not is_safe_url(ready_url):
+ return 'DISABLED'
+ return ready_url
+
+ def clear_watch(self):
+ import pathlib
+
+ # JSON Data, Screenshots, Textfiles (history index and snapshots), HTML in the future etc
+ for item in pathlib.Path(str(self.watch_data_dir)).rglob("*.*"):
+ os.unlink(item)
+
+ # Force the attr to recalculate
+ bump = self.history
+
+ # Do this last because it will trigger a recheck due to last_checked being zero
+ self.update({
+ 'browser_steps_last_error_step': None,
+ 'check_count': 0,
+ 'fetch_time': 0.0,
+ 'has_ldjson_price_data': None,
+ 'last_checked': 0,
+ 'last_error': False,
+ 'last_notification_error': False,
+ 'last_viewed': 0,
+ 'previous_md5': False,
+ 'previous_md5_before_filters': False,
+ 'remote_server_reply': None,
+ 'track_ldjson_price_data': None
+ })
+ watch_check_update = signal('watch_check_update')
+ if watch_check_update:
+ watch_check_update.send(watch_uuid=self.get('uuid'))
+
+ return
+
+ @property
+ def is_source_type_url(self):
+ return self.get('url', '').startswith('source:')
+
+ @property
+ def get_fetch_backend(self):
+ """
+ Like just using the `fetch_backend` key but there could be some logic
+ :return:
+ """
+ # Maybe also if is_image etc?
+ # This is because chrome/playwright wont render the PDF in the browser and we will just fetch it and use pdf2html to see the text.
+ if self.is_pdf:
+ return 'html_requests'
+
+ return self.get('fetch_backend')
+
+ @property
+ def is_pdf(self):
+ # content_type field is set in the future
+ # https://github.com/dgtlmoon/changedetection.io/issues/1392
+ # Not sure the best logic here
+ return self.get('url', '').lower().endswith('.pdf') or 'pdf' in self.get('content_type', '').lower()
+
+ @property
+ def label(self):
+ # Used for sorting
+ return self.get('title') if self.get('title') else self.get('url')
+
+ @property
+ def last_changed(self):
+ # last_changed will be the newest snapshot, but when we have just one snapshot, it should be 0
+ if self.__history_n <= 1:
+ return 0
+ if self.__newest_history_key:
+ return int(self.__newest_history_key)
+ return 0
+
+ @property
+ def history_n(self):
+ return self.__history_n
+
+ @property
+ def history(self):
+ """History index is just a text file as a list
+ {watch-uuid}/history.txt
+
+ contains a list like
+
+ {epoch-time},{filename}\n
+
+ We read in this list as the history information
+
+ """
+ tmp_history = {}
+
+ # In the case we are only using the watch for processing without history
+ if not self.watch_data_dir:
+ return []
+
+ # Read the history file as a dict
+ fname = os.path.join(self.watch_data_dir, "history.txt")
+ if os.path.isfile(fname):
+ logger.debug(f"Reading watch history index for {self.get('uuid')}")
+ with open(fname, "r") as f:
+ for i in f.readlines():
+ if ',' in i:
+ k, v = i.strip().split(',', 2)
+
+ # The index history could contain a relative path, so we need to make the fullpath
+ # so that python can read it
+ if not '/' in v and not '\'' in v:
+ v = os.path.join(self.watch_data_dir, v)
+ else:
+ # It's possible that they moved the datadir on older versions
+ # So the snapshot exists but is in a different path
+ snapshot_fname = v.split('/')[-1]
+ proposed_new_path = os.path.join(self.watch_data_dir, snapshot_fname)
+ if not os.path.exists(v) and os.path.exists(proposed_new_path):
+ v = proposed_new_path
+
+ tmp_history[k] = v
+
+ if len(tmp_history):
+ self.__newest_history_key = list(tmp_history.keys())[-1]
+ else:
+ self.__newest_history_key = None
+
+ self.__history_n = len(tmp_history)
+
+ return tmp_history
+
+ @property
+ def has_history(self):
+ fname = os.path.join(self.watch_data_dir, "history.txt")
+ return os.path.isfile(fname)
+
+ @property
+ def has_browser_steps(self):
+ has_browser_steps = self.get('browser_steps') and list(filter(
+ lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'),
+ self.get('browser_steps')))
+
+ return has_browser_steps
+
+ @property
+ def has_restock_info(self):
+ if self.get('restock') and self['restock'].get('in_stock') != None:
+ return True
+
+ return False
+
+ # Returns the newest key, but if theres only 1 record, then it's counted as not being new, so return 0.
+ @property
+ def newest_history_key(self):
+ if self.__newest_history_key is not None:
+ return self.__newest_history_key
+
+ if len(self.history) <= 1:
+ return 0
+
+
+ bump = self.history
+ return self.__newest_history_key
+
+ # Given an arbitrary timestamp, find the best history key for the [diff] button so it can preset a smarter from_version
+ @property
+ 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'))
+ sorted_keys = sorted(keys, key=lambda x: int(x))
+ sorted_keys.reverse()
+
+ # 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
+
+ # 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
+ filepath = self.history[timestamp]
+
+ # See if a brotli versions exists and switch to that
+ if not filepath.endswith('.br') and os.path.isfile(f"{filepath}.br"):
+ filepath = f"{filepath}.br"
+
+ # OR in the backup case that the .br does not exist, but the plain one does
+ if filepath.endswith('.br') and not os.path.isfile(filepath):
+ if os.path.isfile(filepath.replace('.br', '')):
+ filepath = filepath.replace('.br', '')
+
+ if filepath.endswith('.br'):
+ # Brotli doesnt have a fileheader to detect it, so we rely on filename
+ # https://www.rfc-editor.org/rfc/rfc7932
+ with open(filepath, 'rb') as f:
+ return(brotli.decompress(f.read()).decode('utf-8'))
+
+ with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
+ return f.read()
+
+ # Save some text file to the appropriate path and bump the history
+ # result_obj from fetch_site_status.run()
+ def save_history_text(self, contents, timestamp, snapshot_id):
+ import brotli
+ import tempfile
+ logger.trace(f"{self.get('uuid')} - Updating history.txt with timestamp {timestamp}")
+
+ self.ensure_data_dir_exists()
+
+ threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
+ skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
+
+ # Decide on snapshot filename and destination path
+ if not skip_brotli and len(contents) > threshold:
+ snapshot_fname = f"{snapshot_id}.txt.br"
+ encoded_data = brotli.compress(contents.encode('utf-8'), mode=brotli.MODE_TEXT)
+ else:
+ snapshot_fname = f"{snapshot_id}.txt"
+ encoded_data = contents.encode('utf-8')
+
+ dest = os.path.join(self.watch_data_dir, snapshot_fname)
+
+ # Write snapshot file atomically if it doesn't exist
+ if not os.path.exists(dest):
+ with tempfile.NamedTemporaryFile('wb', delete=False, dir=self.watch_data_dir) as tmp:
+ tmp.write(encoded_data)
+ tmp.flush()
+ os.fsync(tmp.fileno())
+ tmp_path = tmp.name
+ os.rename(tmp_path, dest)
+
+ # Append to history.txt atomically
+ index_fname = os.path.join(self.watch_data_dir, "history.txt")
+ index_line = f"{timestamp},{snapshot_fname}\n"
+
+ # Lets try force flush here since it's usually a very small file
+ # If this still fails in the future then try reading all to memory first, re-writing etc
+ with open(index_fname, 'a', encoding='utf-8') as f:
+ f.write(index_line)
+ f.flush()
+ os.fsync(f.fileno())
+
+ # Update internal state
+ self.__newest_history_key = timestamp
+ self.__history_n += 1
+
+ # @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status
+ return snapshot_fname
+
+ @property
+ def has_empty_checktime(self):
+ # using all() + dictionary comprehension
+ # Check if all values are 0 in dictionary
+ res = all(x == None or x == False or x==0 for x in self.get('time_between_check', {}).values())
+ return res
+
+ def threshold_seconds(self):
+ seconds = 0
+ for m, n in mtable.items():
+ x = self.get('time_between_check', {}).get(m, None)
+ if x:
+ seconds += x * n
+ return seconds
+
+ # Iterate over all history texts and see if something new exists
+ # Always applying .strip() to start/end but optionally replace any other whitespace
+ def lines_contain_something_unique_compared_to_history(self, lines: list, ignore_whitespace=False):
+ local_lines = set([])
+ if lines:
+ if ignore_whitespace:
+ if isinstance(lines[0], str): # Can be either str or bytes depending on what was on the disk
+ local_lines = set([l.translate(TRANSLATE_WHITESPACE_TABLE).lower() for l in lines])
+ else:
+ local_lines = set([l.decode('utf-8').translate(TRANSLATE_WHITESPACE_TABLE).lower() for l in lines])
+ else:
+ if isinstance(lines[0], str): # Can be either str or bytes depending on what was on the disk
+ local_lines = set([l.strip().lower() for l in lines])
+ else:
+ local_lines = set([l.decode('utf-8').strip().lower() for l in lines])
+
+
+ # Compare each lines (set) against each history text file (set) looking for something new..
+ existing_history = set({})
+ for k, v in self.history.items():
+ content = self.get_history_snapshot(k)
+
+ if ignore_whitespace:
+ alist = set([line.translate(TRANSLATE_WHITESPACE_TABLE).lower() for line in content.splitlines()])
+ else:
+ alist = set([line.strip().lower() for line in content.splitlines()])
+
+ existing_history = existing_history.union(alist)
+
+ # Check that everything in local_lines(new stuff) already exists in existing_history - it should
+ # if not, something new happened
+ return not local_lines.issubset(existing_history)
+
+ def get_screenshot(self):
+ fname = os.path.join(self.watch_data_dir, "last-screenshot.png")
+ if os.path.isfile(fname):
+ return fname
+
+ # False is not an option for AppRise, must be type None
+ return None
+
+ def __get_file_ctime(self, filename):
+ fname = os.path.join(self.watch_data_dir, filename)
+ if os.path.isfile(fname):
+ return int(os.path.getmtime(fname))
+ return False
+
+ @property
+ def error_text_ctime(self):
+ return self.__get_file_ctime('last-error.txt')
+
+ @property
+ def snapshot_text_ctime(self):
+ if self.history_n==0:
+ return False
+
+ timestamp = list(self.history.keys())[-1]
+ return int(timestamp)
+
+ @property
+ def snapshot_screenshot_ctime(self):
+ return self.__get_file_ctime('last-screenshot.png')
+
+ @property
+ def snapshot_error_screenshot_ctime(self):
+ return self.__get_file_ctime('last-error-screenshot.png')
+
+ @property
+ def watch_data_dir(self):
+ # The base dir of the watch data
+ return os.path.join(self.__datastore_path, self['uuid']) if self.__datastore_path else None
+
+ def get_error_text(self):
+ """Return the text saved from a previous request that resulted in a non-200 error"""
+ fname = os.path.join(self.watch_data_dir, "last-error.txt")
+ if os.path.isfile(fname):
+ with open(fname, 'r') as f:
+ return f.read()
+ return False
+
+ def get_error_snapshot(self):
+ """Return path to the screenshot that resulted in a non-200 error"""
+ fname = os.path.join(self.watch_data_dir, "last-error-screenshot.png")
+ if os.path.isfile(fname):
+ return fname
+ return False
+
+
+ def pause(self):
+ self['paused'] = True
+
+ def unpause(self):
+ self['paused'] = False
+
+ def toggle_pause(self):
+ self['paused'] ^= True
+
+ def mute(self):
+ self['notification_muted'] = True
+
+ def unmute(self):
+ self['notification_muted'] = False
+
+ def toggle_mute(self):
+ self['notification_muted'] ^= True
+
+ def extra_notification_token_values(self):
+ # Used for providing extra tokens
+ # return {'widget': 555}
+ return {}
+
+ def extra_notification_token_placeholder_info(self):
+ # Used for providing extra tokens
+ # return [('widget', "Get widget amounts")]
+ return []
+
+
+ def extract_regex_from_all_history(self, regex):
+ import csv
+ import re
+ import datetime
+ csv_output_filename = False
+ csv_writer = False
+ f = None
+
+ # self.history will be keyed with the full path
+ for k, fname in self.history.items():
+ if os.path.isfile(fname):
+ if True:
+ contents = self.get_history_snapshot(k)
+ res = re.findall(regex, contents, re.MULTILINE)
+ if res:
+ if not csv_writer:
+ # A file on the disk can be transferred much faster via flask than a string reply
+ csv_output_filename = 'report.csv'
+ f = open(os.path.join(self.watch_data_dir, csv_output_filename), 'w')
+ # @todo some headers in the future
+ #fieldnames = ['Epoch seconds', 'Date']
+ csv_writer = csv.writer(f,
+ delimiter=',',
+ quotechar='"',
+ quoting=csv.QUOTE_MINIMAL,
+ #fieldnames=fieldnames
+ )
+ csv_writer.writerow(['Epoch seconds', 'Date'])
+ # csv_writer.writeheader()
+
+ date_str = datetime.datetime.fromtimestamp(int(k)).strftime('%Y-%m-%d %H:%M:%S')
+ for r in res:
+ row = [k, date_str]
+ if isinstance(r, str):
+ row.append(r)
+ else:
+ row+=r
+ csv_writer.writerow(row)
+
+ if f:
+ f.close()
+
+ return csv_output_filename
+
+
+ def has_special_diff_filter_options_set(self):
+
+ # All False - nothing would be done, so act like it's not processable
+ if not self.get('filter_text_added', True) and not self.get('filter_text_replaced', True) and not self.get('filter_text_removed', True):
+ return False
+
+ # Or one is set
+ if not self.get('filter_text_added', True) or not self.get('filter_text_replaced', True) or not self.get('filter_text_removed', True):
+ return True
+
+ # None is set
+ return False
+
+ def save_error_text(self, contents):
+ self.ensure_data_dir_exists()
+ target_path = os.path.join(self.watch_data_dir, "last-error.txt")
+ with open(target_path, 'w', encoding='utf-8') as f:
+ f.write(contents)
+
+ def save_xpath_data(self, data, as_error=False):
+ import json
+ import zlib
+
+ if as_error:
+ target_path = os.path.join(str(self.watch_data_dir), "elements-error.deflate")
+ else:
+ target_path = os.path.join(str(self.watch_data_dir), "elements.deflate")
+
+ self.ensure_data_dir_exists()
+
+ with open(target_path, 'wb') as f:
+ if not isinstance(data, str):
+ f.write(zlib.compress(json.dumps(data).encode()))
+ else:
+ f.write(zlib.compress(data.encode()))
+ f.close()
+
+ # Save as PNG, PNG is larger but better for doing visual diff in the future
+ def save_screenshot(self, screenshot: bytes, as_error=False):
+
+ if as_error:
+ target_path = os.path.join(self.watch_data_dir, "last-error-screenshot.png")
+ else:
+ target_path = os.path.join(self.watch_data_dir, "last-screenshot.png")
+
+ self.ensure_data_dir_exists()
+
+ with open(target_path, 'wb') as f:
+ f.write(screenshot)
+ f.close()
+
+
+ def get_last_fetched_text_before_filters(self):
+ import brotli
+ filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
+
+ if not os.path.isfile(filepath) or os.path.getsize(filepath) == 0:
+ # If a previous attempt doesnt yet exist, just snarf the previous snapshot instead
+ dates = list(self.history.keys())
+ if len(dates):
+ return self.get_history_snapshot(dates[-1])
+ else:
+ return ''
+
+ with open(filepath, 'rb') as f:
+ return(brotli.decompress(f.read()).decode('utf-8'))
+
+ def save_last_text_fetched_before_filters(self, contents):
+ import brotli
+ filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
+ with open(filepath, 'wb') as f:
+ f.write(brotli.compress(contents, mode=brotli.MODE_TEXT))
+
+ def save_last_fetched_html(self, timestamp, contents):
+ import brotli
+
+ self.ensure_data_dir_exists()
+ snapshot_fname = f"{timestamp}.html.br"
+ filepath = os.path.join(self.watch_data_dir, snapshot_fname)
+
+ with open(filepath, 'wb') as f:
+ contents = contents.encode('utf-8') if isinstance(contents, str) else contents
+ try:
+ f.write(brotli.compress(contents))
+ except Exception as e:
+ logger.warning(f"{self.get('uuid')} - Unable to compress snapshot, saving as raw data to {filepath}")
+ logger.warning(e)
+ f.write(contents)
+
+ self._prune_last_fetched_html_snapshots()
+
+ def get_fetched_html(self, timestamp):
+ import brotli
+
+ snapshot_fname = f"{timestamp}.html.br"
+ filepath = os.path.join(self.watch_data_dir, snapshot_fname)
+ if os.path.isfile(filepath):
+ with open(filepath, 'rb') as f:
+ return (brotli.decompress(f.read()).decode('utf-8'))
+
+ return False
+
+
+ def _prune_last_fetched_html_snapshots(self):
+
+ dates = list(self.history.keys())
+ dates.reverse()
+
+ for index, timestamp in enumerate(dates):
+ snapshot_fname = f"{timestamp}.html.br"
+ filepath = os.path.join(self.watch_data_dir, snapshot_fname)
+
+ # Keep only the first 2
+ if index > 1 and os.path.isfile(filepath):
+ os.remove(filepath)
+
+
+ @property
+ def get_browsersteps_available_screenshots(self):
+ "For knowing which screenshots are available to show the user in BrowserSteps UI"
+ available = []
+ for f in Path(self.watch_data_dir).glob('step_before-*.jpeg'):
+ step_n=re.search(r'step_before-(\d+)', f.name)
+ if step_n:
+ available.append(step_n.group(1))
+ return available
+
+ def compile_error_texts(self, has_proxies=None):
+ """Compile error texts for this watch.
+ Accepts has_proxies parameter to ensure it works even outside app context"""
+ from flask import url_for
+ from markupsafe import Markup
+
+ output = [] # Initialize as list since we're using append
+ last_error = self.get('last_error','')
+
+ try:
+ url_for('settings.settings_page')
+ except Exception as e:
+ has_app_context = False
+ else:
+ has_app_context = True
+
+ # has app+request context, we can use url_for()
+ if has_app_context:
+ if last_error:
+ if '403' in last_error:
+ if has_proxies:
+ output.append(str(Markup(f"{last_error} - Try other proxies/location '")))
+ else:
+ output.append(str(Markup(f"{last_error} - Try adding external proxies/locations '")))
+ else:
+ output.append(str(Markup(last_error)))
+
+ if self.get('last_notification_error'):
+ output.append(str(Markup(f"")))
+
+ else:
+ # Lo_Fi version
+ if last_error:
+ output.append(str(Markup(last_error)))
+ if self.get('last_notification_error'):
+ output.append(str(Markup(self.get('last_notification_error'))))
+
+ res = "\n".join(output)
+ return res
+
diff --git a/changedetectionio/model/__init__.py b/changedetectionio/model/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2a6587aa2067ea065699d1a988a3b1f3803e4efe
--- /dev/null
+++ b/changedetectionio/model/__init__.py
@@ -0,0 +1,136 @@
+import os
+import uuid
+
+from changedetectionio import strtobool
+default_notification_format_for_watch = 'System default'
+
+class watch_base(dict):
+
+ def __init__(self, *arg, **kw):
+ self.update({
+ # Custom notification content
+ # Re #110, so then if this is set to None, we know to use the default value instead
+ # Requires setting to None on submit if it's the same as the default
+ # Should be all None by default, so we use the system default in this case.
+ 'body': None,
+ 'browser_steps': [],
+ 'browser_steps_last_error_step': None,
+ 'check_count': 0,
+ 'check_unique_lines': False, # On change-detected, compare against all history if its something new
+ 'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine.
+ 'content-type': None,
+ 'date_created': None,
+ 'extract_text': [], # Extract text by regex after filters
+ 'extract_title_as_title': False,
+ 'fetch_backend': 'system', # plaintext, playwright etc
+ 'fetch_time': 0.0,
+ 'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),
+ 'filter_text_added': True,
+ 'filter_text_removed': True,
+ 'filter_text_replaced': True,
+ 'follow_price_changes': True,
+ 'has_ldjson_price_data': None,
+ 'headers': {}, # Extra headers to send
+ 'ignore_text': [], # List of text to ignore when calculating the comparison checksum
+ 'in_stock_only': True, # Only trigger change on going to instock from out-of-stock
+ 'include_filters': [],
+ 'last_checked': 0,
+ 'last_error': False,
+ 'last_notification_error': None,
+ 'last_viewed': 0, # history key value of the last viewed via the [diff] link
+ 'method': 'GET',
+ 'notification_alert_count': 0,
+ 'notification_body': None,
+ 'notification_format': default_notification_format_for_watch,
+ 'notification_muted': False,
+ 'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL
+ 'notification_title': None,
+ 'notification_urls': [], # List of URLs to add to the notification Queue (Usually AppRise)
+ 'paused': False,
+ 'previous_md5': False,
+ 'previous_md5_before_filters': False, # Used for skipping changedetection entirely
+ 'processor': 'text_json_diff', # could be restock_diff or others from .processors
+ 'price_change_threshold_percent': None,
+ 'proxy': None, # Preferred proxy connection
+ 'remote_server_reply': None, # From 'server' reply header
+ 'sort_text_alphabetically': False,
+ 'subtractive_selectors': [],
+ 'tag': '', # Old system of text name for a tag, to be removed
+ 'tags': [], # list of UUIDs to App.Tags
+ 'text_should_not_be_present': [], # Text that should not present
+ 'time_between_check': {'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': None},
+ 'time_between_check_use_default': True,
+ "time_schedule_limit": {
+ "enabled": False,
+ "monday": {
+ "enabled": True,
+ "start_time": "00:00",
+ "duration": {
+ "hours": "24",
+ "minutes": "00"
+ }
+ },
+ "tuesday": {
+ "enabled": True,
+ "start_time": "00:00",
+ "duration": {
+ "hours": "24",
+ "minutes": "00"
+ }
+ },
+ "wednesday": {
+ "enabled": True,
+ "start_time": "00:00",
+ "duration": {
+ "hours": "24",
+ "minutes": "00"
+ }
+ },
+ "thursday": {
+ "enabled": True,
+ "start_time": "00:00",
+ "duration": {
+ "hours": "24",
+ "minutes": "00"
+ }
+ },
+ "friday": {
+ "enabled": True,
+ "start_time": "00:00",
+ "duration": {
+ "hours": "24",
+ "minutes": "00"
+ }
+ },
+ "saturday": {
+ "enabled": True,
+ "start_time": "00:00",
+ "duration": {
+ "hours": "24",
+ "minutes": "00"
+ }
+ },
+ "sunday": {
+ "enabled": True,
+ "start_time": "00:00",
+ "duration": {
+ "hours": "24",
+ "minutes": "00"
+ }
+ },
+ },
+ 'title': None,
+ 'track_ldjson_price_data': None,
+ 'trim_text_whitespace': False,
+ 'remove_duplicate_lines': False,
+ 'trigger_text': [], # List of text or regex to wait for until a change is detected
+ 'url': '',
+ 'uuid': str(uuid.uuid4()),
+ 'webdriver_delay': None,
+ 'webdriver_js_execute_code': None, # Run before change-detection
+ })
+
+ super(watch_base, self).__init__(*arg, **kw)
+
+ if self.get('default'):
+ del self['default']
\ No newline at end of file
diff --git a/changedetectionio/notification/__init__.py b/changedetectionio/notification/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0129f2440e80c4589e2b93f34e80900f976b00ea
--- /dev/null
+++ b/changedetectionio/notification/__init__.py
@@ -0,0 +1,35 @@
+from changedetectionio.model import default_notification_format_for_watch
+
+ult_notification_format_for_watch = 'System default'
+default_notification_format = 'HTML Color'
+default_notification_body = '{{watch_url}} had a change.\n---\n{{diff}}\n---\n'
+default_notification_title = 'ChangeDetection.io Notification - {{watch_url}}'
+
+# The values (markdown etc) are from apprise NotifyFormat,
+# But to avoid importing the whole heavy module just use the same strings here.
+valid_notification_formats = {
+ 'Text': 'text',
+ 'Markdown': 'markdown',
+ 'HTML': 'html',
+ 'HTML Color': 'htmlcolor',
+ # Used only for editing a watch (not for global)
+ default_notification_format_for_watch: default_notification_format_for_watch
+}
+
+
+valid_tokens = {
+ 'base_url': '',
+ 'current_snapshot': '',
+ 'diff': '',
+ 'diff_added': '',
+ 'diff_full': '',
+ 'diff_patch': '',
+ 'diff_removed': '',
+ 'diff_url': '',
+ 'preview_url': '',
+ 'triggered_text': '',
+ 'watch_tag': '',
+ 'watch_title': '',
+ 'watch_url': '',
+ 'watch_uuid': '',
+}
diff --git a/changedetectionio/notification/apprise_plugin/__init__.py b/changedetectionio/notification/apprise_plugin/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/changedetectionio/notification/apprise_plugin/assets.py b/changedetectionio/notification/apprise_plugin/assets.py
new file mode 100644
index 0000000000000000000000000000000000000000..4e6392efc0e4f30f1534e024c6fe66d746a2e61b
--- /dev/null
+++ b/changedetectionio/notification/apprise_plugin/assets.py
@@ -0,0 +1,16 @@
+from apprise import AppriseAsset
+
+# Refer to:
+# https://github.com/caronc/apprise/wiki/Development_API#the-apprise-asset-object
+
+APPRISE_APP_ID = "changedetection.io"
+APPRISE_APP_DESC = "ChangeDetection.io best and simplest website monitoring and change detection"
+APPRISE_APP_URL = "https://changedetection.io"
+APPRISE_AVATAR_URL = "https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png"
+
+apprise_asset = AppriseAsset(
+ app_id=APPRISE_APP_ID,
+ app_desc=APPRISE_APP_DESC,
+ app_url=APPRISE_APP_URL,
+ image_url_logo=APPRISE_AVATAR_URL,
+)
diff --git a/changedetectionio/notification/apprise_plugin/custom_handlers.py b/changedetectionio/notification/apprise_plugin/custom_handlers.py
new file mode 100644
index 0000000000000000000000000000000000000000..1fd28b416166a44e7fbeb0d24f7673a32e4f1d73
--- /dev/null
+++ b/changedetectionio/notification/apprise_plugin/custom_handlers.py
@@ -0,0 +1,112 @@
+import json
+import re
+from urllib.parse import unquote_plus
+
+import requests
+from apprise.decorators import notify
+from apprise.utils.parse import parse_url as apprise_parse_url
+from loguru import logger
+from requests.structures import CaseInsensitiveDict
+
+SUPPORTED_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head"}
+
+
+def notify_supported_methods(func):
+ for method in SUPPORTED_HTTP_METHODS:
+ func = notify(on=method)(func)
+ # Add support for https, for each supported http method
+ func = notify(on=f"{method}s")(func)
+ return func
+
+
+def _get_auth(parsed_url: dict) -> str | tuple[str, str]:
+ user: str | None = parsed_url.get("user")
+ password: str | None = parsed_url.get("password")
+
+ if user is not None and password is not None:
+ return (unquote_plus(user), unquote_plus(password))
+
+ if user is not None:
+ return unquote_plus(user)
+
+ return ""
+
+
+def _get_headers(parsed_url: dict, body: str) -> CaseInsensitiveDict:
+ headers = CaseInsensitiveDict(
+ {unquote_plus(k).title(): unquote_plus(v) for k, v in parsed_url["qsd+"].items()}
+ )
+
+ # If Content-Type is not specified, guess if the body is a valid JSON
+ if headers.get("Content-Type") is None:
+ try:
+ json.loads(body)
+ headers["Content-Type"] = "application/json; charset=utf-8"
+ except Exception:
+ pass
+
+ return headers
+
+
+def _get_params(parsed_url: dict) -> CaseInsensitiveDict:
+ # 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
+ params = CaseInsensitiveDict(
+ {
+ unquote_plus(k): unquote_plus(v)
+ for k, v in parsed_url["qsd"].items()
+ if k.strip("-") not in parsed_url["qsd-"]
+ and k.strip("+") not in parsed_url["qsd+"]
+ }
+ )
+
+ return params
+
+
+@notify_supported_methods
+def apprise_http_custom_handler(
+ body: str,
+ title: str,
+ notify_type: str,
+ meta: dict,
+ *args,
+ **kwargs,
+) -> bool:
+ url: str = meta.get("url")
+ schema: str = meta.get("schema")
+ method: str = re.sub(r"s$", "", schema).upper()
+
+ # Convert /foobar?+some-header=hello to proper header dictionary
+ parsed_url: dict[str, str | dict | None] | None = apprise_parse_url(url)
+ if parsed_url is None:
+ return False
+
+ auth = _get_auth(parsed_url=parsed_url)
+ headers = _get_headers(parsed_url=parsed_url, body=body)
+ params = _get_params(parsed_url=parsed_url)
+
+ url = re.sub(rf"^{schema}", "https" if schema.endswith("s") else "http", parsed_url.get("url"))
+
+ try:
+ response = requests.request(
+ method=method,
+ url=url,
+ auth=auth,
+ headers=headers,
+ params=params,
+ data=body.encode("utf-8") if isinstance(body, str) else body,
+ )
+
+ response.raise_for_status()
+
+ logger.info(f"Successfully sent custom notification to {url}")
+ return True
+
+ except requests.RequestException as e:
+ logger.error(f"Remote host error while sending custom notification to {url}: {e}")
+ return False
+
+ except Exception as e:
+ logger.error(f"Unexpected error occurred while sending custom notification to {url}: {e}")
+ return False
diff --git a/changedetectionio/notification/handler.py b/changedetectionio/notification/handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc7628fe36f474133ca4991b1be33bc78b1c95a3
--- /dev/null
+++ b/changedetectionio/notification/handler.py
@@ -0,0 +1,193 @@
+
+import time
+import apprise
+from loguru import logger
+from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL
+
+def process_notification(n_object, datastore):
+ from changedetectionio.safe_jinja import render as jinja_render
+ from . import default_notification_format_for_watch, default_notification_format, valid_notification_formats
+ # be sure its registered
+ from .apprise_plugin.custom_handlers import apprise_http_custom_handler
+
+ now = time.time()
+ if n_object.get('notification_timestamp'):
+ logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s")
+ # Insert variables into the notification content
+ notification_parameters = create_notification_parameters(n_object, datastore)
+
+ n_format = valid_notification_formats.get(
+ n_object.get('notification_format', default_notification_format),
+ valid_notification_formats[default_notification_format],
+ )
+
+ # If we arrived with 'System default' then look it up
+ if n_format == default_notification_format_for_watch and datastore.data['settings']['application'].get('notification_format') != default_notification_format_for_watch:
+ # Initially text or whatever
+ n_format = datastore.data['settings']['application'].get('notification_format', valid_notification_formats[default_notification_format])
+
+ logger.trace(f"Complete notification body including Jinja and placeholders calculated in {time.time() - now:.2f}s")
+
+ # https://github.com/caronc/apprise/wiki/Development_LogCapture
+ # Anything higher than or equal to WARNING (which covers things like Connection errors)
+ # raise it as an exception
+
+ sent_objs = []
+
+ if 'as_async' in n_object:
+ apprise_asset.async_mode = n_object.get('as_async')
+
+ apobj = apprise.Apprise(debug=True, asset=apprise_asset)
+
+ if not n_object.get('notification_urls'):
+ return None
+
+ with apprise.LogCapture(level=apprise.logging.DEBUG) as logs:
+ for url in n_object['notification_urls']:
+
+ # Get the notification body from datastore
+ n_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters)
+ if n_object.get('notification_format', '').startswith('HTML'):
+ n_body = n_body.replace("\n", ' ')
+
+ n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)
+
+ url = url.strip()
+ if url.startswith('#'):
+ logger.trace(f"Skipping commented out notification URL - {url}")
+ continue
+
+ if not url:
+ logger.warning(f"Process Notification: skipping empty notification URL.")
+ continue
+
+ logger.info(f">> Process Notification: AppRise notifying {url}")
+ url = jinja_render(template_str=url, **notification_parameters)
+
+ # Re 323 - Limit discord length to their 2000 char limit total or it wont send.
+ # Because different notifications may require different pre-processing, run each sequentially :(
+ # 2000 bytes minus -
+ # 200 bytes for the overhead of the _entire_ json payload, 200 bytes for {tts, wait, content} etc headers
+ # Length of URL - Incase they specify a longer custom avatar_url
+
+ # So if no avatar_url is specified, add one so it can be correctly calculated into the total payload
+ k = '?' if not '?' in url else '&'
+ if not 'avatar_url' in url \
+ and not url.startswith('mail') \
+ and not url.startswith('post') \
+ and not url.startswith('get') \
+ and not url.startswith('delete') \
+ and not url.startswith('put'):
+ url += k + f"avatar_url={APPRISE_AVATAR_URL}"
+
+ if url.startswith('tgram://'):
+ # Telegram only supports a limit subset of HTML, remove the ' ' we place in.
+ # re https://github.com/dgtlmoon/changedetection.io/issues/555
+ # @todo re-use an existing library we have already imported to strip all non-allowed tags
+ n_body = n_body.replace(' ', '\n')
+ n_body = n_body.replace('', '\n')
+ # real limit is 4096, but minus some for extra metadata
+ payload_max_size = 3600
+ body_limit = max(0, payload_max_size - len(n_title))
+ n_title = n_title[0:payload_max_size]
+ n_body = n_body[0:body_limit]
+
+ elif url.startswith('discord://') or url.startswith('https://discordapp.com/api/webhooks') or url.startswith(
+ 'https://discord.com/api'):
+ # real limit is 2000, but minus some for extra metadata
+ payload_max_size = 1700
+ body_limit = max(0, payload_max_size - len(n_title))
+ n_title = n_title[0:payload_max_size]
+ n_body = n_body[0:body_limit]
+
+ elif url.startswith('mailto'):
+ # Apprise will default to HTML, so we need to override it
+ # So that whats' generated in n_body is in line with what is going to be sent.
+ # https://github.com/caronc/apprise/issues/633#issuecomment-1191449321
+ if not 'format=' in url and (n_format == 'Text' or n_format == 'Markdown'):
+ prefix = '?' if not '?' in url else '&'
+ # Apprise format is lowercase text https://github.com/caronc/apprise/issues/633
+ n_format = n_format.lower()
+ url = f"{url}{prefix}format={n_format}"
+ # If n_format == HTML, then apprise email should default to text/html and we should be sending HTML only
+
+ apobj.add(url)
+
+ sent_objs.append({'title': n_title,
+ 'body': n_body,
+ 'url': url,
+ 'body_format': n_format})
+
+ # Blast off the notifications tht are set in .add()
+ apobj.notify(
+ title=n_title,
+ body=n_body,
+ body_format=n_format,
+ # False is not an option for AppRise, must be type None
+ attach=n_object.get('screenshot', None)
+ )
+
+
+ # Returns empty string if nothing found, multi-line string otherwise
+ log_value = logs.getvalue()
+
+ if log_value and 'WARNING' in log_value or 'ERROR' in log_value:
+ logger.critical(log_value)
+ raise Exception(log_value)
+
+ # Return what was sent for better logging - after the for loop
+ return sent_objs
+
+
+# Notification title + body content parameters get created here.
+# ( Where we prepare the tokens in the notification to be replaced with actual values )
+def create_notification_parameters(n_object, datastore):
+ from copy import deepcopy
+ from . import valid_tokens
+
+ # in the case we send a test notification from the main settings, there is no UUID.
+ uuid = n_object['uuid'] if 'uuid' in n_object else ''
+
+ if uuid:
+ watch_title = datastore.data['watching'][uuid].get('title', '')
+ tag_list = []
+ tags = datastore.get_all_tags_for_watch(uuid)
+ if tags:
+ for tag_uuid, tag in tags.items():
+ tag_list.append(tag.get('title'))
+ watch_tag = ', '.join(tag_list)
+ else:
+ watch_title = 'Change Detection'
+ watch_tag = ''
+
+ # Create URLs to customise the notification with
+ # active_base_url - set in store.py data property
+ base_url = datastore.data['settings']['application'].get('active_base_url')
+
+ watch_url = n_object['watch_url']
+
+ diff_url = "{}/diff/{}".format(base_url, uuid)
+ preview_url = "{}/preview/{}".format(base_url, uuid)
+
+ # Not sure deepcopy is needed here, but why not
+ tokens = deepcopy(valid_tokens)
+
+ # Valid_tokens also used as a field validator
+ tokens.update(
+ {
+ 'base_url': base_url,
+ 'diff_url': diff_url,
+ 'preview_url': preview_url,
+ 'watch_tag': watch_tag if watch_tag is not None else '',
+ 'watch_title': watch_title if watch_title is not None else '',
+ 'watch_url': watch_url,
+ 'watch_uuid': uuid,
+ })
+
+ # n_object will contain diff, diff_added etc etc
+ tokens.update(n_object)
+
+ if uuid:
+ tokens.update(datastore.data['watching'].get(uuid).extra_notification_token_values())
+
+ return tokens
diff --git a/changedetectionio/notification_service.py b/changedetectionio/notification_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..5f3136b8112380e939bfefbbedf0837c9216daf0
--- /dev/null
+++ b/changedetectionio/notification_service.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+
+"""
+Notification Service Module
+Extracted from update_worker.py to provide standalone notification functionality
+for both sync and async workers
+"""
+
+import time
+from loguru import logger
+
+
+class NotificationService:
+ """
+ Standalone notification service that handles all notification functionality
+ previously embedded in the update_worker class
+ """
+
+ def __init__(self, datastore, notification_q):
+ self.datastore = datastore
+ self.notification_q = notification_q
+
+ def queue_notification_for_watch(self, n_object, watch):
+ """
+ Queue a notification for a watch with full diff rendering and template variables
+ """
+ from changedetectionio import diff
+ from changedetectionio.notification import default_notification_format_for_watch
+
+ dates = []
+ trigger_text = ''
+
+ now = time.time()
+
+ if watch:
+ watch_history = watch.history
+ dates = list(watch_history.keys())
+ trigger_text = watch.get('trigger_text', [])
+
+ # Add text that was triggered
+ if len(dates):
+ snapshot_contents = watch.get_history_snapshot(dates[-1])
+ else:
+ snapshot_contents = "No snapshot/history available, the watch should fetch atleast once."
+
+ # If we ended up here with "System default"
+ if n_object.get('notification_format') == default_notification_format_for_watch:
+ n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format')
+
+ html_colour_enable = False
+ # HTML needs linebreak, but MarkDown and Text can use a linefeed
+ if n_object.get('notification_format') == 'HTML':
+ line_feed_sep = " "
+ # Snapshot will be plaintext on the disk, convert to some kind of HTML
+ snapshot_contents = snapshot_contents.replace('\n', line_feed_sep)
+ elif n_object.get('notification_format') == 'HTML Color':
+ line_feed_sep = " "
+ # Snapshot will be plaintext on the disk, convert to some kind of HTML
+ snapshot_contents = snapshot_contents.replace('\n', line_feed_sep)
+ html_colour_enable = True
+ else:
+ line_feed_sep = "\n"
+
+ triggered_text = ''
+ if len(trigger_text):
+ from . import html_tools
+ triggered_text = html_tools.get_triggered_text(content=snapshot_contents, trigger_text=trigger_text)
+ if triggered_text:
+ triggered_text = line_feed_sep.join(triggered_text)
+
+ # Could be called as a 'test notification' with only 1 snapshot available
+ prev_snapshot = "Example text: example test\nExample text: change detection is cool\nExample text: some more examples\n"
+ current_snapshot = "Example text: example test\nExample text: change detection is fantastic\nExample text: even more examples\nExample text: a lot more examples"
+
+ if len(dates) > 1:
+ prev_snapshot = watch.get_history_snapshot(dates[-2])
+ current_snapshot = watch.get_history_snapshot(dates[-1])
+
+ n_object.update({
+ 'current_snapshot': snapshot_contents,
+ 'diff': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=line_feed_sep, html_colour=html_colour_enable),
+ 'diff_added': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False, line_feed_sep=line_feed_sep),
+ 'diff_full': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True, line_feed_sep=line_feed_sep, html_colour=html_colour_enable),
+ 'diff_patch': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=line_feed_sep, patch_format=True),
+ 'diff_removed': diff.render_diff(prev_snapshot, current_snapshot, include_added=False, line_feed_sep=line_feed_sep),
+ 'notification_timestamp': now,
+ 'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None,
+ 'triggered_text': triggered_text,
+ 'uuid': watch.get('uuid') if watch else None,
+ 'watch_url': watch.get('url') if watch else None,
+ })
+
+ if watch:
+ n_object.update(watch.extra_notification_token_values())
+
+ logger.trace(f"Main rendered notification placeholders (diff_added etc) calculated in {time.time()-now:.3f}s")
+ logger.debug("Queued notification for sending")
+ self.notification_q.put(n_object)
+
+ def _check_cascading_vars(self, var_name, watch):
+ """
+ Check notification variables in cascading priority:
+ Individual watch settings > Tag settings > Global settings
+ """
+ from changedetectionio.notification import (
+ default_notification_format_for_watch,
+ default_notification_body,
+ default_notification_title
+ )
+
+ # Would be better if this was some kind of Object where Watch can reference the parent datastore etc
+ v = watch.get(var_name)
+ if v and not watch.get('notification_muted'):
+ if var_name == 'notification_format' and v == default_notification_format_for_watch:
+ return self.datastore.data['settings']['application'].get('notification_format')
+
+ return v
+
+ tags = self.datastore.get_all_tags_for_watch(uuid=watch.get('uuid'))
+ if tags:
+ for tag_uuid, tag in tags.items():
+ v = tag.get(var_name)
+ if v and not tag.get('notification_muted'):
+ return v
+
+ if self.datastore.data['settings']['application'].get(var_name):
+ return self.datastore.data['settings']['application'].get(var_name)
+
+ # Otherwise could be defaults
+ if var_name == 'notification_format':
+ return default_notification_format_for_watch
+ if var_name == 'notification_body':
+ return default_notification_body
+ if var_name == 'notification_title':
+ return default_notification_title
+
+ return None
+
+ def send_content_changed_notification(self, watch_uuid):
+ """
+ Send notification when content changes are detected
+ """
+ n_object = {}
+ watch = self.datastore.data['watching'].get(watch_uuid)
+ if not watch:
+ return
+
+ watch_history = watch.history
+ dates = list(watch_history.keys())
+ # Theoretically it's possible that this could be just 1 long,
+ # - In the case that the timestamp key was not unique
+ if len(dates) == 1:
+ raise ValueError(
+ "History index had 2 or more, but only 1 date loaded, timestamps were not unique? maybe two of the same timestamps got written, needs more delay?"
+ )
+
+ # Should be a better parent getter in the model object
+
+ # Prefer - Individual watch settings > Tag settings > Global settings (in that order)
+ n_object['notification_urls'] = self._check_cascading_vars('notification_urls', watch)
+ n_object['notification_title'] = self._check_cascading_vars('notification_title', watch)
+ n_object['notification_body'] = self._check_cascading_vars('notification_body', watch)
+ n_object['notification_format'] = self._check_cascading_vars('notification_format', watch)
+
+ # (Individual watch) Only prepare to notify if the rules above matched
+ queued = False
+ if n_object and n_object.get('notification_urls'):
+ queued = True
+
+ count = watch.get('notification_alert_count', 0) + 1
+ self.datastore.update_watch(uuid=watch_uuid, update_obj={'notification_alert_count': count})
+
+ self.queue_notification_for_watch(n_object=n_object, watch=watch)
+
+ return queued
+
+ def send_filter_failure_notification(self, watch_uuid):
+ """
+ Send notification when CSS/XPath filters fail consecutively
+ """
+ threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts')
+ watch = self.datastore.data['watching'].get(watch_uuid)
+ if not watch:
+ return
+
+ n_object = {'notification_title': 'Changedetection.io - Alert - CSS/xPath filter was not present in the page',
+ 'notification_body': "Your configured CSS/xPath filters of '{}' for {{{{watch_url}}}} did not appear on the page after {} attempts, did the page change layout?\n\nLink: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}\n\nThanks - Your omniscient changedetection.io installation :)\n".format(
+ ", ".join(watch['include_filters']),
+ threshold),
+ 'notification_format': 'text'}
+
+ if len(watch['notification_urls']):
+ n_object['notification_urls'] = watch['notification_urls']
+
+ elif len(self.datastore.data['settings']['application']['notification_urls']):
+ n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls']
+
+ # Only prepare to notify if the rules above matched
+ if 'notification_urls' in n_object:
+ n_object.update({
+ 'watch_url': watch['url'],
+ 'uuid': watch_uuid,
+ 'screenshot': None
+ })
+ self.notification_q.put(n_object)
+ logger.debug(f"Sent filter not found notification for {watch_uuid}")
+ else:
+ logger.debug(f"NOT sending filter not found notification for {watch_uuid} - no notification URLs")
+
+ def send_step_failure_notification(self, watch_uuid, step_n):
+ """
+ Send notification when browser steps fail consecutively
+ """
+ watch = self.datastore.data['watching'].get(watch_uuid, False)
+ if not watch:
+ return
+ threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts')
+ n_object = {'notification_title': "Changedetection.io - Alert - Browser step at position {} could not be run".format(step_n+1),
+ 'notification_body': "Your configured browser step at position {} for {{{{watch_url}}}} "
+ "did not appear on the page after {} attempts, did the page change layout? "
+ "Does it need a delay added?\n\nLink: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}\n\n"
+ "Thanks - Your omniscient changedetection.io installation :)\n".format(step_n+1, threshold),
+ 'notification_format': 'text'}
+
+ if len(watch['notification_urls']):
+ n_object['notification_urls'] = watch['notification_urls']
+
+ elif len(self.datastore.data['settings']['application']['notification_urls']):
+ n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls']
+
+ # Only prepare to notify if the rules above matched
+ if 'notification_urls' in n_object:
+ n_object.update({
+ 'watch_url': watch['url'],
+ 'uuid': watch_uuid
+ })
+ self.notification_q.put(n_object)
+ logger.error(f"Sent step not found notification for {watch_uuid}")
+
+
+# Convenience functions for creating notification service instances
+def create_notification_service(datastore, notification_q):
+ """
+ Factory function to create a NotificationService instance
+ """
+ return NotificationService(datastore, notification_q)
\ No newline at end of file
diff --git a/changedetectionio/pluggy_interface.py b/changedetectionio/pluggy_interface.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe2f7182eedfe5c0d9d26638a8abb4977858a208
--- /dev/null
+++ b/changedetectionio/pluggy_interface.py
@@ -0,0 +1,82 @@
+import pluggy
+import os
+import importlib
+import sys
+
+# Global plugin namespace for changedetection.io
+PLUGIN_NAMESPACE = "changedetectionio"
+
+hookspec = pluggy.HookspecMarker(PLUGIN_NAMESPACE)
+hookimpl = pluggy.HookimplMarker(PLUGIN_NAMESPACE)
+
+
+class ChangeDetectionSpec:
+ """Hook specifications for extending changedetection.io functionality."""
+
+ @hookspec
+ def ui_edit_stats_extras(watch):
+ """Return HTML content to add to the stats tab in the edit view.
+
+ Args:
+ watch: The watch object being edited
+
+ Returns:
+ str: HTML content to be inserted in the stats tab
+ """
+ pass
+
+
+# Set up Plugin Manager
+plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
+
+# Register hookspecs
+plugin_manager.add_hookspecs(ChangeDetectionSpec)
+
+# Load plugins from subdirectories
+def load_plugins_from_directories():
+ # Dictionary of directories to scan for plugins
+ plugin_dirs = {
+ 'conditions': os.path.join(os.path.dirname(__file__), 'conditions', 'plugins'),
+ # Add more plugin directories here as needed
+ }
+
+ # Note: Removed the direct import of example_word_count_plugin as it's now in the conditions/plugins directory
+
+ for dir_name, dir_path in plugin_dirs.items():
+ if not os.path.exists(dir_path):
+ continue
+
+ # Get all Python files (excluding __init__.py)
+ for filename in os.listdir(dir_path):
+ if filename.endswith(".py") and filename != "__init__.py":
+ module_name = filename[:-3] # Remove .py extension
+ module_path = f"changedetectionio.{dir_name}.plugins.{module_name}"
+
+ try:
+ module = importlib.import_module(module_path)
+ # Register the plugin with pluggy
+ plugin_manager.register(module, module_name)
+ except (ImportError, AttributeError) as e:
+ print(f"Error loading plugin {module_name}: {e}")
+
+# Load plugins
+load_plugins_from_directories()
+
+# Discover installed plugins from external packages (if any)
+plugin_manager.load_setuptools_entrypoints(PLUGIN_NAMESPACE)
+
+# Helper function to collect UI stats extras from all plugins
+def collect_ui_edit_stats_extras(watch):
+ """Collect and combine HTML content from all plugins that implement ui_edit_stats_extras"""
+ extras_content = []
+
+ # Get all plugins that implement the ui_edit_stats_extras hook
+ results = plugin_manager.hook.ui_edit_stats_extras(watch=watch)
+
+ # If we have results, add them to our content
+ if results:
+ for result in results:
+ if result: # Skip empty results
+ extras_content.append(result)
+
+ return "\n".join(extras_content) if extras_content else ""
\ No newline at end of file
diff --git a/changedetectionio/processors/README.md b/changedetectionio/processors/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..0cc5557204f4db39901e6a6ff40fb896eb06f9b3
--- /dev/null
+++ b/changedetectionio/processors/README.md
@@ -0,0 +1,15 @@
+# Change detection post-processors
+
+The concept here is to be able to switch between different domain specific problems to solve.
+
+- `text_json_diff` The traditional text and JSON comparison handler
+- `restock_diff` Only cares about detecting if a product looks like it has some text that suggests that it's out of stock, otherwise assumes that it's in stock.
+
+Some suggestions for the future
+
+- `graphical`
+
+## Todo
+
+- Make each processor return a extra list of sub-processed (so you could configure a single processor in different ways)
+- move restock_diff to its own pip/github repo
diff --git a/changedetectionio/processors/__init__.py b/changedetectionio/processors/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2ae6df4d6ee6b059c9dab14f9f5f8e87546a3cc6
--- /dev/null
+++ b/changedetectionio/processors/__init__.py
@@ -0,0 +1,255 @@
+from abc import abstractmethod
+from changedetectionio.content_fetchers.base import Fetcher
+from changedetectionio.strtobool import strtobool
+from copy import deepcopy
+from loguru import logger
+import hashlib
+import importlib
+import inspect
+import os
+import pkgutil
+import re
+
+class difference_detection_processor():
+
+ browser_steps = None
+ datastore = None
+ fetcher = None
+ screenshot = None
+ watch = None
+ xpath_data = None
+ preferred_proxy = None
+
+ def __init__(self, *args, datastore, watch_uuid, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.datastore = datastore
+ self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid))
+ # Generic fetcher that should be extended (requests, playwright etc)
+ self.fetcher = Fetcher()
+
+ async def call_browser(self, preferred_proxy_id=None):
+
+ from requests.structures import CaseInsensitiveDict
+
+ url = self.watch.link
+
+ # 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."
+ )
+
+ # Requests, playwright, other browser via wss:// etc, fetch_extra_something
+ prefer_fetch_backend = self.watch.get('fetch_backend', 'system')
+
+ # Proxy ID "key"
+ preferred_proxy_id = preferred_proxy_id if preferred_proxy_id else self.datastore.get_preferred_proxy_for_watch(uuid=self.watch.get('uuid'))
+
+ # Pluggable content self.fetcher
+ if not prefer_fetch_backend or prefer_fetch_backend == 'system':
+ prefer_fetch_backend = self.datastore.data['settings']['application'].get('fetch_backend')
+
+ # In the case that the preferred fetcher was a browser config with custom connection URL..
+ # @todo - on save watch, if its extra_browser_ then it should be obvious it will use playwright (like if its requests now..)
+ custom_browser_connection_url = None
+ if prefer_fetch_backend.startswith('extra_browser_'):
+ (t, key) = prefer_fetch_backend.split('extra_browser_')
+ connection = list(
+ filter(lambda s: (s['browser_name'] == key), self.datastore.data['settings']['requests'].get('extra_browsers', [])))
+ if connection:
+ prefer_fetch_backend = 'html_webdriver'
+ custom_browser_connection_url = connection[0].get('browser_connection_url')
+
+ # PDF should be html_requests because playwright will serve it up (so far) in a embedded page
+ # @todo https://github.com/dgtlmoon/changedetection.io/issues/2019
+ # @todo needs test to or a fix
+ if self.watch.is_pdf:
+ prefer_fetch_backend = "html_requests"
+
+ # Grab the right kind of 'fetcher', (playwright, requests, etc)
+ from changedetectionio import content_fetchers
+ if hasattr(content_fetchers, prefer_fetch_backend):
+ # @todo TEMPORARY HACK - SWITCH BACK TO PLAYWRIGHT FOR BROWSERSTEPS
+ if prefer_fetch_backend == 'html_webdriver' and self.watch.has_browser_steps:
+ # This is never supported in selenium anyway
+ logger.warning("Using playwright fetcher override for possible puppeteer request in browsersteps, because puppetteer:browser steps is incomplete.")
+ from changedetectionio.content_fetchers.playwright import fetcher as playwright_fetcher
+ fetcher_obj = playwright_fetcher
+ else:
+ fetcher_obj = getattr(content_fetchers, prefer_fetch_backend)
+ else:
+ # What it referenced doesnt exist, Just use a default
+ fetcher_obj = getattr(content_fetchers, "html_requests")
+
+ proxy_url = None
+ if preferred_proxy_id:
+ # Custom browser endpoints should NOT have a proxy added
+ if not prefer_fetch_backend.startswith('extra_browser_'):
+ proxy_url = self.datastore.proxy_list.get(preferred_proxy_id).get('url')
+ logger.debug(f"Selected proxy key '{preferred_proxy_id}' as proxy URL '{proxy_url}' for {url}")
+ else:
+ logger.debug("Skipping adding proxy data when custom Browser endpoint is specified. ")
+
+ # Now call the fetcher (playwright/requests/etc) with arguments that only a fetcher would need.
+ # When browser_connection_url is None, it method should default to working out whats the best defaults (os env vars etc)
+ self.fetcher = fetcher_obj(proxy_override=proxy_url,
+ custom_browser_connection_url=custom_browser_connection_url
+ )
+
+ if self.watch.has_browser_steps:
+ self.fetcher.browser_steps = self.watch.get('browser_steps', [])
+ self.fetcher.browser_steps_screenshot_path = os.path.join(self.datastore.datastore_path, self.watch.get('uuid'))
+
+ # Tweak the base config with the per-watch ones
+ from changedetectionio.safe_jinja import render as jinja_render
+ request_headers = CaseInsensitiveDict()
+
+ ua = self.datastore.data['settings']['requests'].get('default_ua')
+ if ua and ua.get(prefer_fetch_backend):
+ request_headers.update({'User-Agent': ua.get(prefer_fetch_backend)})
+
+ request_headers.update(self.watch.get('headers', {}))
+ request_headers.update(self.datastore.get_all_base_headers())
+ request_headers.update(self.datastore.get_all_headers_in_textfile_for_watch(uuid=self.watch.get('uuid')))
+
+ # https://github.com/psf/requests/issues/4525
+ # Requests doesnt yet support brotli encoding, so don't put 'br' here, be totally sure that the user cannot
+ # do this by accident.
+ if 'Accept-Encoding' in request_headers and "br" in request_headers['Accept-Encoding']:
+ request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '')
+
+ for header_name in request_headers:
+ request_headers.update({header_name: jinja_render(template_str=request_headers.get(header_name))})
+
+ timeout = self.datastore.data['settings']['requests'].get('timeout')
+
+ request_body = self.watch.get('body')
+ if request_body:
+ request_body = jinja_render(template_str=self.watch.get('body'))
+
+ request_method = self.watch.get('method')
+ ignore_status_codes = self.watch.get('ignore_status_codes', False)
+
+ # Configurable per-watch or global extra delay before extracting text (for webDriver types)
+ system_webdriver_delay = self.datastore.data['settings']['application'].get('webdriver_delay', None)
+ if self.watch.get('webdriver_delay'):
+ self.fetcher.render_extract_delay = self.watch.get('webdriver_delay')
+ elif system_webdriver_delay is not None:
+ self.fetcher.render_extract_delay = system_webdriver_delay
+
+ if self.watch.get('webdriver_js_execute_code') is not None and self.watch.get('webdriver_js_execute_code').strip():
+ self.fetcher.webdriver_js_execute_code = self.watch.get('webdriver_js_execute_code')
+
+ # Requests for PDF's, images etc should be passwd the is_binary flag
+ is_binary = self.watch.is_pdf
+
+ # And here we go! call the right browser with browser-specific settings
+ empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False)
+
+ # All fetchers are now async
+ await self.fetcher.run(url=url,
+ timeout=timeout,
+ request_headers=request_headers,
+ request_body=request_body,
+ request_method=request_method,
+ ignore_status_codes=ignore_status_codes,
+ current_include_filters=self.watch.get('include_filters'),
+ is_binary=is_binary,
+ empty_pages_are_a_change=empty_pages_are_a_change
+ )
+
+ #@todo .quit here could go on close object, so we can run JS if change-detected
+ self.fetcher.quit(watch=self.watch)
+
+ # After init, call run_changedetection() which will do the actual change-detection
+
+ @abstractmethod
+ def run_changedetection(self, watch):
+ update_obj = {'last_notification_error': False, 'last_error': False}
+ some_data = 'xxxxx'
+ update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest()
+ changed_detected = False
+ return changed_detected, update_obj, ''.encode('utf-8')
+
+
+def find_sub_packages(package_name):
+ """
+ Find all sub-packages within the given package.
+
+ :param package_name: The name of the base package to scan for sub-packages.
+ :return: A list of sub-package names.
+ """
+ package = importlib.import_module(package_name)
+ return [name for _, name, is_pkg in pkgutil.iter_modules(package.__path__) if is_pkg]
+
+
+def find_processors():
+ """
+ Find all subclasses of DifferenceDetectionProcessor in the specified package.
+
+ :param package_name: The name of the package to scan for processor modules.
+ :return: A list of (module, class) tuples.
+ """
+ package_name = "changedetectionio.processors" # Name of the current package/module
+
+ processors = []
+ sub_packages = find_sub_packages(package_name)
+
+ for sub_package in sub_packages:
+ module_name = f"{package_name}.{sub_package}.processor"
+ try:
+ module = importlib.import_module(module_name)
+
+ # Iterate through all classes in the module
+ for name, obj in inspect.getmembers(module, inspect.isclass):
+ if issubclass(obj, difference_detection_processor) and obj is not difference_detection_processor:
+ processors.append((module, sub_package))
+ except (ModuleNotFoundError, ImportError) as e:
+ logger.warning(f"Failed to import module {module_name}: {e} (find_processors())")
+
+ return processors
+
+
+def get_parent_module(module):
+ module_name = module.__name__
+ if '.' not in module_name:
+ return None # Top-level module has no parent
+ parent_module_name = module_name.rsplit('.', 1)[0]
+ try:
+ return importlib.import_module(parent_module_name)
+ except Exception as e:
+ pass
+
+ return False
+
+
+
+def get_custom_watch_obj_for_processor(processor_name):
+ from changedetectionio.model import Watch
+ watch_class = Watch.model
+ processor_classes = find_processors()
+ custom_watch_obj = next((tpl for tpl in processor_classes if tpl[1] == processor_name), None)
+ if custom_watch_obj:
+ # Parent of .processor.py COULD have its own Watch implementation
+ parent_module = get_parent_module(custom_watch_obj[0])
+ if hasattr(parent_module, 'Watch'):
+ watch_class = parent_module.Watch
+
+ return watch_class
+
+
+def available_processors():
+ """
+ Get a list of processors by name and description for the UI elements
+ :return: A list :)
+ """
+
+ processor_classes = find_processors()
+
+ available = []
+ for package, processor_class in processor_classes:
+ available.append((processor_class, package.name))
+
+ return available
+
diff --git a/changedetectionio/processors/exceptions.py b/changedetectionio/processors/exceptions.py
new file mode 100644
index 0000000000000000000000000000000000000000..01c99a637c84ddbab01e1c402c9416c2e4a93f8e
--- /dev/null
+++ b/changedetectionio/processors/exceptions.py
@@ -0,0 +1,10 @@
+class ProcessorException(Exception):
+ def __init__(self, message=None, status_code=None, url=None, screenshot=None, has_filters=False, html_content='', xpath_data=None):
+ self.message = message
+ self.status_code = status_code
+ self.url = url
+ self.screenshot = screenshot
+ self.has_filters = has_filters
+ self.html_content = html_content
+ self.xpath_data = xpath_data
+ return
diff --git a/changedetectionio/processors/restock_diff/__init__.py b/changedetectionio/processors/restock_diff/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..3d472beece01062a0bd682a1184f3b92260a179d
--- /dev/null
+++ b/changedetectionio/processors/restock_diff/__init__.py
@@ -0,0 +1,84 @@
+
+from babel.numbers import parse_decimal
+from changedetectionio.model.Watch import model as BaseWatch
+from typing import Union
+import re
+
+class Restock(dict):
+
+ def parse_currency(self, raw_value: str) -> Union[float, None]:
+ # Clean and standardize the value (ie 1,400.00 should be 1400.00), even better would be store the whole thing as an integer.
+ standardized_value = raw_value
+
+ if ',' in standardized_value and '.' in standardized_value:
+ # Identify the correct decimal separator
+ if standardized_value.rfind('.') > standardized_value.rfind(','):
+ standardized_value = standardized_value.replace(',', '')
+ else:
+ standardized_value = standardized_value.replace('.', '').replace(',', '.')
+ else:
+ standardized_value = standardized_value.replace(',', '.')
+
+ # Remove any non-numeric characters except for the decimal point
+ standardized_value = re.sub(r'[^\d.-]', '', standardized_value)
+
+ if standardized_value:
+ # Convert to float
+ return float(parse_decimal(standardized_value, locale='en'))
+
+ return None
+
+ def __init__(self, *args, **kwargs):
+ # Define default values
+ default_values = {
+ 'in_stock': None,
+ 'price': None,
+ 'currency': None,
+ 'original_price': None
+ }
+
+ # Initialize the dictionary with default values
+ super().__init__(default_values)
+
+ # Update with any provided positional arguments (dictionaries)
+ if args:
+ if len(args) == 1 and isinstance(args[0], dict):
+ self.update(args[0])
+ else:
+ raise ValueError("Only one positional argument of type 'dict' is allowed")
+
+ def __setitem__(self, key, value):
+ # Custom logic to handle setting price and original_price
+ if key == 'price' or key == 'original_price':
+ if isinstance(value, str):
+ value = self.parse_currency(raw_value=value)
+
+ super().__setitem__(key, value)
+
+class Watch(BaseWatch):
+ def __init__(self, *arg, **kw):
+ super().__init__(*arg, **kw)
+ self['restock'] = Restock(kw['default']['restock']) if kw.get('default') and kw['default'].get('restock') else Restock()
+
+ self['restock_settings'] = kw['default']['restock_settings'] if kw.get('default',{}).get('restock_settings') else {
+ 'follow_price_changes': True,
+ 'in_stock_processing' : 'in_stock_only'
+ } #@todo update
+
+ def clear_watch(self):
+ super().clear_watch()
+ self.update({'restock': Restock()})
+
+ def extra_notification_token_values(self):
+ values = super().extra_notification_token_values()
+ values['restock'] = self.get('restock', {})
+ return values
+
+ def extra_notification_token_placeholder_info(self):
+ values = super().extra_notification_token_placeholder_info()
+
+ values.append(('restock.price', "Price detected"))
+ values.append(('restock.original_price', "Original price at first check"))
+
+ return values
+
diff --git a/changedetectionio/processors/restock_diff/forms.py b/changedetectionio/processors/restock_diff/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..39334aa3c715dd61e780c3e4a277d12e6aeffbbc
--- /dev/null
+++ b/changedetectionio/processors/restock_diff/forms.py
@@ -0,0 +1,81 @@
+from wtforms import (
+ BooleanField,
+ validators,
+ FloatField
+)
+from wtforms.fields.choices import RadioField
+from wtforms.fields.form import FormField
+from wtforms.form import Form
+
+from changedetectionio.forms import processor_text_json_diff_form
+
+
+class RestockSettingsForm(Form):
+ in_stock_processing = RadioField(label='Re-stock detection', choices=[
+ ('in_stock_only', "In Stock only (Out Of Stock -> In Stock only)"),
+ ('all_changes', "Any availability changes"),
+ ('off', "Off, don't follow availability/restock"),
+ ], default="in_stock_only")
+
+ price_change_min = FloatField('Below price to trigger notification', [validators.Optional()],
+ render_kw={"placeholder": "No limit", "size": "10"})
+ price_change_max = FloatField('Above price to trigger notification', [validators.Optional()],
+ render_kw={"placeholder": "No limit", "size": "10"})
+ price_change_threshold_percent = FloatField('Threshold in % for price changes since the original price', validators=[
+
+ validators.Optional(),
+ validators.NumberRange(min=0, max=100, message="Should be between 0 and 100"),
+ ], render_kw={"placeholder": "0%", "size": "5"})
+
+ follow_price_changes = BooleanField('Follow price changes', default=True)
+
+class processor_settings_form(processor_text_json_diff_form):
+ restock_settings = FormField(RestockSettingsForm)
+
+ def extra_tab_content(self):
+ return 'Restock & Price Detection'
+
+ def extra_form_content(self):
+ output = ""
+
+ if getattr(self, 'watch', None) and getattr(self, 'datastore'):
+ for tag_uuid in self.watch.get('tags'):
+ tag = self.datastore.data['settings']['application']['tags'].get(tag_uuid, {})
+ if tag.get('overrides_watch'):
+ # @todo - Quick and dirty, cant access 'url_for' here because its out of scope somehow
+ output = f"""Note! A Group tag overrides the restock and price detection here.
"""
+
+ output += """
+ {% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
+
+
+
+
+
+ {{ render_field(form.restock_settings.in_stock_processing) }}
+
+
+ {{ render_checkbox_field(form.restock_settings.follow_price_changes) }}
+ Changes in price should trigger a notification
+
+
+ {{ render_field(form.restock_settings.price_change_min, placeholder=watch.get('restock', {}).get('price')) }}
+ Minimum amount, Trigger a change/notification when the price drops below this value.
+
+
+ {{ render_field(form.restock_settings.price_change_max, placeholder=watch.get('restock', {}).get('price')) }}
+ Maximum amount, Trigger a change/notification when the price rises above this value.
+
+
+ {{ render_field(form.restock_settings.price_change_threshold_percent) }}
+ Price must change more than this % to trigger a change since the first check.
+ For example, If the product is $1,000 USD originally, 2% would mean it has to change more than $20 since the first check.
+
+
+
+ """
+ return output
\ No newline at end of file
diff --git a/changedetectionio/processors/restock_diff/processor.py b/changedetectionio/processors/restock_diff/processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..1fa81058caa7a3dcbec984f2ce54d8a624585245
--- /dev/null
+++ b/changedetectionio/processors/restock_diff/processor.py
@@ -0,0 +1,314 @@
+from .. import difference_detection_processor
+from ..exceptions import ProcessorException
+from . import Restock
+from loguru import logger
+
+import urllib3
+import time
+
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+name = 'Re-stock & Price detection for pages with a SINGLE product'
+description = 'Detects if the product goes back to in-stock'
+
+class UnableToExtractRestockData(Exception):
+ def __init__(self, status_code):
+ # Set this so we can use it in other parts of the app
+ self.status_code = status_code
+ return
+
+class MoreThanOnePriceFound(Exception):
+ def __init__(self):
+ return
+
+def _search_prop_by_value(matches, value):
+ for properties in matches:
+ for prop in properties:
+ if value in prop[0]:
+ return prop[1] # Yield the desired value and exit the function
+
+def _deduplicate_prices(data):
+ import re
+
+ '''
+ Some price data has multiple entries, OR it has a single entry with ['$159', '159', 159, "$ 159"] or just "159"
+ Get all the values, clean it and add it to a set then return the unique values
+ '''
+ unique_data = set()
+
+ # Return the complete 'datum' where its price was not seen before
+ for datum in data:
+
+ if isinstance(datum.value, list):
+ # Process each item in the list
+ normalized_value = set([float(re.sub(r'[^\d.]', '', str(item))) for item in datum.value if str(item).strip()])
+ unique_data.update(normalized_value)
+ else:
+ # Process single value
+ v = float(re.sub(r'[^\d.]', '', str(datum.value)))
+ unique_data.add(v)
+
+ return list(unique_data)
+
+
+# should return Restock()
+# add casting?
+def get_itemprop_availability(html_content) -> Restock:
+ """
+ Kind of funny/cool way to find price/availability in one many different possibilities.
+ Use 'extruct' to find any possible RDFa/microdata/json-ld data, make a JSON string from the output then search it.
+ """
+ from jsonpath_ng import parse
+
+ import re
+ now = time.time()
+ import extruct
+ logger.trace(f"Imported extruct module in {time.time() - now:.3f}s")
+
+ now = time.time()
+
+ # Extruct is very slow, I'm wondering if some ML is going to be faster (800ms on my i7), 'rdfa' seems to be the heaviest.
+ syntaxes = ['dublincore', 'json-ld', 'microdata', 'microformat', 'opengraph']
+ try:
+ data = extruct.extract(html_content, syntaxes=syntaxes)
+ except Exception as e:
+ logger.warning(f"Unable to extract data, document parsing with extruct failed with {type(e).__name__} - {str(e)}")
+ return Restock()
+
+ logger.trace(f"Extruct basic extract of all metadata done in {time.time() - now:.3f}s")
+
+ # First phase, dead simple scanning of anything that looks useful
+ value = Restock()
+ if data:
+ logger.debug("Using jsonpath to find price/availability/etc")
+ price_parse = parse('$..(price|Price)')
+ pricecurrency_parse = parse('$..(pricecurrency|currency|priceCurrency )')
+ availability_parse = parse('$..(availability|Availability)')
+
+ price_result = _deduplicate_prices(price_parse.find(data))
+ if price_result:
+ # Right now, we just support single product items, maybe we will store the whole actual metadata seperately in teh future and
+ # parse that for the UI?
+ if len(price_result) > 1 and len(price_result) > 1:
+ # See of all prices are different, in the case that one product has many embedded data types with the same price
+ # One might have $121.95 and another 121.95 etc
+ logger.warning(f"More than one price found {price_result}, throwing exception, cant use this plugin.")
+ raise MoreThanOnePriceFound()
+
+ value['price'] = price_result[0]
+
+ pricecurrency_result = pricecurrency_parse.find(data)
+ if pricecurrency_result:
+ value['currency'] = pricecurrency_result[0].value
+
+ availability_result = availability_parse.find(data)
+ if availability_result:
+ value['availability'] = availability_result[0].value
+
+ if value.get('availability'):
+ value['availability'] = re.sub(r'(?i)^(https|http)://schema.org/', '',
+ value.get('availability').strip(' "\'').lower()) if value.get('availability') else None
+
+ # Second, go dig OpenGraph which is something that jsonpath_ng cant do because of the tuples and double-dots (:)
+ if not value.get('price') or value.get('availability'):
+ logger.debug("Alternatively digging through OpenGraph properties for restock/price info..")
+ jsonpath_expr = parse('$..properties')
+
+ for match in jsonpath_expr.find(data):
+ if not value.get('price'):
+ value['price'] = _search_prop_by_value([match.value], "price:amount")
+ if not value.get('availability'):
+ value['availability'] = _search_prop_by_value([match.value], "product:availability")
+ if not value.get('currency'):
+ value['currency'] = _search_prop_by_value([match.value], "price:currency")
+ logger.trace(f"Processed with Extruct in {time.time()-now:.3f}s")
+
+ return value
+
+
+def is_between(number, lower=None, upper=None):
+ """
+ Check if a number is between two values.
+
+ Parameters:
+ number (float): The number to check.
+ lower (float or None): The lower bound (inclusive). If None, no lower bound.
+ upper (float or None): The upper bound (inclusive). If None, no upper bound.
+
+ Returns:
+ bool: True if the number is between the lower and upper bounds, False otherwise.
+ """
+ return (lower is None or lower <= number) and (upper is None or number <= upper)
+
+
+class perform_site_check(difference_detection_processor):
+ screenshot = None
+ xpath_data = None
+
+ def run_changedetection(self, watch):
+ import hashlib
+
+ if not watch:
+ raise Exception("Watch no longer exists.")
+
+ # Unset any existing notification error
+ update_obj = {'last_notification_error': False, 'last_error': False, 'restock': Restock()}
+
+ self.screenshot = self.fetcher.screenshot
+ self.xpath_data = self.fetcher.xpath_data
+
+ # Track the content type
+ update_obj['content_type'] = self.fetcher.headers.get('Content-Type', '')
+ update_obj["last_check_status"] = self.fetcher.get_last_status_code()
+
+ # Only try to process restock information (like scraping for keywords) if the page was actually rendered correctly.
+ # Otherwise it will assume "in stock" because nothing suggesting the opposite was found
+ from ...html_tools import html_to_text
+ text = html_to_text(self.fetcher.content)
+ logger.debug(f"Length of text after conversion: {len(text)}")
+ if not len(text):
+ from ...content_fetchers.exceptions import ReplyWithContentButNoText
+ raise ReplyWithContentButNoText(url=watch.link,
+ status_code=self.fetcher.get_last_status_code(),
+ screenshot=self.fetcher.screenshot,
+ html_content=self.fetcher.content,
+ xpath_data=self.fetcher.xpath_data
+ )
+
+ # Which restock settings to compare against?
+ restock_settings = watch.get('restock_settings', {})
+
+ # See if any tags have 'activate for individual watches in this tag/group?' enabled and use the first we find
+ for tag_uuid in watch.get('tags'):
+ tag = self.datastore.data['settings']['application']['tags'].get(tag_uuid, {})
+ if tag.get('overrides_watch'):
+ restock_settings = tag.get('restock_settings', {})
+ logger.info(f"Watch {watch.get('uuid')} - Tag '{tag.get('title')}' selected for restock settings override")
+ break
+
+
+ itemprop_availability = {}
+ try:
+ itemprop_availability = get_itemprop_availability(self.fetcher.content)
+ except MoreThanOnePriceFound as e:
+ # Add the real data
+ raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
+ url=watch.get('url'),
+ status_code=self.fetcher.get_last_status_code(),
+ screenshot=self.fetcher.screenshot,
+ xpath_data=self.fetcher.xpath_data
+ )
+
+ # Something valid in get_itemprop_availability() by scraping metadata ?
+ if itemprop_availability.get('price') or itemprop_availability.get('availability'):
+ # Store for other usage
+ update_obj['restock'] = itemprop_availability
+
+ if itemprop_availability.get('availability'):
+ # @todo: Configurable?
+ if any(substring.lower() in itemprop_availability['availability'].lower() for substring in [
+ 'instock',
+ 'instoreonly',
+ 'limitedavailability',
+ 'onlineonly',
+ 'presale']
+ ):
+ update_obj['restock']['in_stock'] = True
+ else:
+ update_obj['restock']['in_stock'] = False
+
+ # Main detection method
+ fetched_md5 = None
+
+ # store original price if not set
+ if itemprop_availability and itemprop_availability.get('price') and not itemprop_availability.get('original_price'):
+ itemprop_availability['original_price'] = itemprop_availability.get('price')
+ update_obj['restock']["original_price"] = itemprop_availability.get('price')
+
+ if not self.fetcher.instock_data and not itemprop_availability.get('availability') and not itemprop_availability.get('price'):
+ raise ProcessorException(
+ message=f"Unable to extract restock data for this page unfortunately. (Got code {self.fetcher.get_last_status_code()} from server), no embedded stock information was found and nothing interesting in the text, try using this watch with Chrome.",
+ url=watch.get('url'),
+ status_code=self.fetcher.get_last_status_code(),
+ screenshot=self.fetcher.screenshot,
+ xpath_data=self.fetcher.xpath_data
+ )
+
+ logger.debug(f"self.fetcher.instock_data is - '{self.fetcher.instock_data}' and itemprop_availability.get('availability') is {itemprop_availability.get('availability')}")
+ # Nothing automatic in microdata found, revert to scraping the page
+ if self.fetcher.instock_data and itemprop_availability.get('availability') is None:
+ # 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold.
+ # Careful! this does not really come from chrome/js when the watch is set to plaintext
+ update_obj['restock']["in_stock"] = True if self.fetcher.instock_data == 'Possibly in stock' else False
+ logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned instock_data - '{self.fetcher.instock_data}' from JS scraper.")
+
+ # Very often websites will lie about the 'availability' in the metadata, so if the scraped version says its NOT in stock, use that.
+ if self.fetcher.instock_data and self.fetcher.instock_data != 'Possibly in stock':
+ if update_obj['restock'].get('in_stock'):
+ logger.warning(
+ f"Lie detected in the availability machine data!! when scraping said its not in stock!! itemprop was '{itemprop_availability}' and scraped from browser was '{self.fetcher.instock_data}' update obj was {update_obj['restock']} ")
+ logger.warning(f"Setting instock to FALSE, scraper found '{self.fetcher.instock_data}' in the body but metadata reported not-in-stock")
+ update_obj['restock']["in_stock"] = False
+
+ # What we store in the snapshot
+ price = update_obj.get('restock').get('price') if update_obj.get('restock').get('price') else ""
+ snapshot_content = f"In Stock: {update_obj.get('restock').get('in_stock')} - Price: {price}"
+
+ # Main detection method
+ fetched_md5 = hashlib.md5(snapshot_content.encode('utf-8')).hexdigest()
+
+ # The main thing that all this at the moment comes down to :)
+ changed_detected = False
+ logger.debug(f"Watch UUID {watch.get('uuid')} restock check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}")
+
+ # out of stock -> back in stock only?
+ if watch.get('restock') and watch['restock'].get('in_stock') != update_obj['restock'].get('in_stock'):
+ # Yes if we only care about it going to instock, AND we are in stock
+ if restock_settings.get('in_stock_processing') == 'in_stock_only' and update_obj['restock']['in_stock']:
+ changed_detected = True
+
+ if restock_settings.get('in_stock_processing') == 'all_changes':
+ # All cases
+ changed_detected = True
+
+ if restock_settings.get('follow_price_changes') and watch.get('restock') and update_obj.get('restock') and update_obj['restock'].get('price'):
+ price = float(update_obj['restock'].get('price'))
+ # Default to current price if no previous price found
+ if watch['restock'].get('original_price'):
+ previous_price = float(watch['restock'].get('original_price'))
+ # It was different, but negate it further down
+ if price != previous_price:
+ changed_detected = True
+
+ # Minimum/maximum price limit
+ if update_obj.get('restock') and update_obj['restock'].get('price'):
+ logger.debug(
+ f"{watch.get('uuid')} - Change was detected, 'price_change_max' is '{restock_settings.get('price_change_max', '')}' 'price_change_min' is '{restock_settings.get('price_change_min', '')}', price from website is '{update_obj['restock'].get('price', '')}'.")
+ if update_obj['restock'].get('price'):
+ min_limit = float(restock_settings.get('price_change_min')) if restock_settings.get('price_change_min') else None
+ max_limit = float(restock_settings.get('price_change_max')) if restock_settings.get('price_change_max') else None
+
+ price = float(update_obj['restock'].get('price'))
+ logger.debug(f"{watch.get('uuid')} after float conversion - Min limit: '{min_limit}' Max limit: '{max_limit}' Price: '{price}'")
+ if min_limit or max_limit:
+ if is_between(number=price, lower=min_limit, upper=max_limit):
+ # Price was between min/max limit, so there was nothing todo in any case
+ logger.trace(f"{watch.get('uuid')} {price} is between {min_limit} and {max_limit}, nothing to check, forcing changed_detected = False (was {changed_detected})")
+ changed_detected = False
+ else:
+ logger.trace(f"{watch.get('uuid')} {price} is between {min_limit} and {max_limit}, continuing normal comparison")
+
+ # Price comparison by %
+ if watch['restock'].get('original_price') and changed_detected and restock_settings.get('price_change_threshold_percent'):
+ previous_price = float(watch['restock'].get('original_price'))
+ pc = float(restock_settings.get('price_change_threshold_percent'))
+ change = abs((price - previous_price) / previous_price * 100)
+ if change and change <= pc:
+ logger.debug(f"{watch.get('uuid')} Override change-detected to FALSE because % threshold ({pc}%) was {change:.3f}%")
+ changed_detected = False
+ else:
+ logger.debug(f"{watch.get('uuid')} Price change was {change:.3f}% , (threshold {pc}%)")
+
+ # Always record the new checksum
+ update_obj["previous_md5"] = fetched_md5
+
+ return changed_detected, update_obj, snapshot_content.strip()
diff --git a/changedetectionio/processors/text_json_diff/__init__.py b/changedetectionio/processors/text_json_diff/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..14053374ab5b7294c8fdf90e3b469ef06d0f71ea
--- /dev/null
+++ b/changedetectionio/processors/text_json_diff/__init__.py
@@ -0,0 +1,114 @@
+
+from loguru import logger
+
+
+
+def _task(watch, update_handler):
+ from changedetectionio.content_fetchers.exceptions import ReplyWithContentButNoText
+ from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
+
+ text_after_filter = ''
+
+ try:
+ # The slow process (we run 2 of these in parallel)
+ changed_detected, update_obj, text_after_filter = update_handler.run_changedetection(watch=watch)
+ except FilterNotFoundInResponse as e:
+ text_after_filter = f"Filter not found in HTML: {str(e)}"
+ except ReplyWithContentButNoText as e:
+ text_after_filter = "Filter found but no text (empty result)"
+ except Exception as e:
+ text_after_filter = f"Error: {str(e)}"
+
+ if not text_after_filter.strip():
+ text_after_filter = 'Empty content'
+
+ # because run_changedetection always returns bytes due to saving the snapshots etc
+ text_after_filter = text_after_filter.decode('utf-8') if isinstance(text_after_filter, bytes) else text_after_filter
+
+ return text_after_filter
+
+
+def prepare_filter_prevew(datastore, watch_uuid, form_data):
+ '''Used by @app.route("/edit//preview-rendered", methods=['POST'])'''
+ from changedetectionio import forms, html_tools
+ from changedetectionio.model.Watch import model as watch_model
+ from concurrent.futures import ProcessPoolExecutor
+ from copy import deepcopy
+ from flask import request
+ import brotli
+ import importlib
+ import os
+ import time
+ now = time.time()
+
+ text_after_filter = ''
+ text_before_filter = ''
+ trigger_line_numbers = []
+ ignore_line_numbers = []
+
+ tmp_watch = deepcopy(datastore.data['watching'].get(watch_uuid))
+
+ if tmp_watch and tmp_watch.history and os.path.isdir(tmp_watch.watch_data_dir):
+ # Splice in the temporary stuff from the form
+ form = forms.processor_text_json_diff_form(formdata=form_data if request.method == 'POST' else None,
+ data=form_data
+ )
+
+ # Only update vars that came in via the AJAX post
+ p = {k: v for k, v in form.data.items() if k in form_data.keys()}
+ tmp_watch.update(p)
+ blank_watch_no_filters = watch_model()
+ blank_watch_no_filters['url'] = tmp_watch.get('url')
+
+ latest_filename = next(reversed(tmp_watch.history))
+ html_fname = os.path.join(tmp_watch.watch_data_dir, f"{latest_filename}.html.br")
+ with open(html_fname, 'rb') as f:
+ decompressed_data = brotli.decompress(f.read()).decode('utf-8') if html_fname.endswith('.br') else f.read().decode('utf-8')
+
+ # Just like a normal change detection except provide a fake "watch" object and dont call .call_browser()
+ processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor")
+ update_handler = processor_module.perform_site_check(datastore=datastore,
+ watch_uuid=tmp_watch.get('uuid') # probably not needed anymore anyway?
+ )
+ # Use the last loaded HTML as the input
+ update_handler.datastore = datastore
+ update_handler.fetcher.content = str(decompressed_data) # str() because playwright/puppeteer/requests return string
+ update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type')
+
+ # Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk
+ # Do this as a parallel process because it could take some time
+ with ProcessPoolExecutor(max_workers=2) as executor:
+ future1 = executor.submit(_task, tmp_watch, update_handler)
+ future2 = executor.submit(_task, blank_watch_no_filters, update_handler)
+
+ text_after_filter = future1.result()
+ text_before_filter = future2.result()
+
+ try:
+ trigger_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
+ wordlist=tmp_watch['trigger_text'],
+ mode='line numbers'
+ )
+ except Exception as e:
+ text_before_filter = f"Error: {str(e)}"
+
+ try:
+ text_to_ignore = tmp_watch.get('ignore_text', []) + datastore.data['settings']['application'].get('global_ignore_text', [])
+ ignore_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
+ wordlist=text_to_ignore,
+ mode='line numbers'
+ )
+ except Exception as e:
+ text_before_filter = f"Error: {str(e)}"
+
+ logger.trace(f"Parsed in {time.time() - now:.3f}s")
+
+ return ({
+ 'after_filter': text_after_filter,
+ 'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter,
+ 'duration': time.time() - now,
+ 'trigger_line_numbers': trigger_line_numbers,
+ 'ignore_line_numbers': ignore_line_numbers,
+ })
+
+
diff --git a/changedetectionio/processors/text_json_diff/processor.py b/changedetectionio/processors/text_json_diff/processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..760aabaef0a2c06a7e74985a17a16ded01812eea
--- /dev/null
+++ b/changedetectionio/processors/text_json_diff/processor.py
@@ -0,0 +1,387 @@
+# HTML to TEXT/JSON DIFFERENCE self.fetcher
+
+import hashlib
+import json
+import os
+import re
+import urllib3
+
+from changedetectionio.conditions import execute_ruleset_against_all_plugins
+from changedetectionio.processors import difference_detection_processor
+from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE
+from changedetectionio import html_tools, content_fetchers
+from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
+from loguru import logger
+
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+name = 'Webpage Text/HTML, JSON and PDF changes'
+description = 'Detects all text changes where possible'
+
+json_filter_prefixes = ['json:', 'jq:', 'jqraw:']
+
+class FilterNotFoundInResponse(ValueError):
+ def __init__(self, msg, screenshot=None, xpath_data=None):
+ self.screenshot = screenshot
+ self.xpath_data = xpath_data
+ ValueError.__init__(self, msg)
+
+
+class PDFToHTMLToolNotFound(ValueError):
+ def __init__(self, msg):
+ ValueError.__init__(self, msg)
+
+
+# Some common stuff here that can be moved to a base class
+# (set_proxy_from_list)
+class perform_site_check(difference_detection_processor):
+
+ def run_changedetection(self, watch):
+ changed_detected = False
+ html_content = ""
+ screenshot = False # as bytes
+ stripped_text_from_html = ""
+
+ if not watch:
+ raise Exception("Watch no longer exists.")
+
+ # Unset any existing notification error
+ update_obj = {'last_notification_error': False, 'last_error': False}
+
+ url = watch.link
+
+ self.screenshot = self.fetcher.screenshot
+ self.xpath_data = self.fetcher.xpath_data
+
+ # Track the content type
+ update_obj['content_type'] = self.fetcher.get_all_headers().get('content-type', '').lower()
+
+ # Watches added automatically in the queue manager will skip if its the same checksum as the previous run
+ # Saves a lot of CPU
+ update_obj['previous_md5_before_filters'] = hashlib.md5(self.fetcher.content.encode('utf-8')).hexdigest()
+
+ # Fetching complete, now filters
+
+ # @note: I feel like the following should be in a more obvious chain system
+ # - Check filter text
+ # - Is the checksum different?
+ # - Do we convert to JSON?
+ # https://stackoverflow.com/questions/41817578/basic-method-chaining ?
+ # return content().textfilter().jsonextract().checksumcompare() ?
+
+ is_json = 'application/json' in self.fetcher.get_all_headers().get('content-type', '').lower()
+ is_html = not is_json
+ is_rss = False
+
+ ctype_header = self.fetcher.get_all_headers().get('content-type', '').lower()
+ # Go into RSS preprocess for converting CDATA/comment to usable text
+ if any(substring in ctype_header for substring in ['application/xml', 'application/rss', 'text/xml']):
+ if '', metadata + '')
+
+ # Better would be if Watch.model could access the global data also
+ # and then use getattr https://docs.python.org/3/reference/datamodel.html#object.__getitem__
+ # https://realpython.com/inherit-python-dict/ instead of doing it procedurely
+ include_filters_from_tags = self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='include_filters')
+
+ # 1845 - remove duplicated filters in both group and watch include filter
+ include_filters_rule = list(dict.fromkeys(watch.get('include_filters', []) + include_filters_from_tags))
+
+ subtractive_selectors = [*self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='subtractive_selectors'),
+ *watch.get("subtractive_selectors", []),
+ *self.datastore.data["settings"]["application"].get("global_subtractive_selectors", [])
+ ]
+
+ # Inject a virtual LD+JSON price tracker rule
+ if watch.get('track_ldjson_price_data', '') == PRICE_DATA_TRACK_ACCEPT:
+ include_filters_rule += html_tools.LD_JSON_PRODUCT_OFFER_SELECTORS
+
+ has_filter_rule = len(include_filters_rule) and len(include_filters_rule[0].strip())
+ has_subtractive_selectors = len(subtractive_selectors) and len(subtractive_selectors[0].strip())
+
+ if is_json and not has_filter_rule:
+ include_filters_rule.append("json:$")
+ has_filter_rule = True
+
+ if is_json:
+ # Sort the JSON so we dont get false alerts when the content is just re-ordered
+ try:
+ self.fetcher.content = json.dumps(json.loads(self.fetcher.content), sort_keys=True)
+ except Exception as e:
+ # Might have just been a snippet, or otherwise bad JSON, continue
+ pass
+
+ if has_filter_rule:
+ for filter in include_filters_rule:
+ if any(prefix in filter for prefix in json_filter_prefixes):
+ stripped_text_from_html += html_tools.extract_json_as_string(content=self.fetcher.content, json_filter=filter)
+ is_html = False
+
+ if is_html or watch.is_source_type_url:
+
+ # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text
+ self.fetcher.content = html_tools.workarounds_for_obfuscations(self.fetcher.content)
+ html_content = self.fetcher.content
+
+ # If not JSON, and if it's not text/plain..
+ if 'text/plain' in self.fetcher.get_all_headers().get('content-type', '').lower():
+ # Don't run get_text or xpath/css filters on plaintext
+ stripped_text_from_html = html_content
+ else:
+ # Does it have some ld+json price data? used for easier monitoring
+ update_obj['has_ldjson_price_data'] = html_tools.has_ldjson_product_info(self.fetcher.content)
+
+ # Then we assume HTML
+ if has_filter_rule:
+ html_content = ""
+
+ for filter_rule in include_filters_rule:
+ # For HTML/XML we offer xpath as an option, just start a regular xPath "/.."
+ if filter_rule[0] == '/' or filter_rule.startswith('xpath:'):
+ html_content += html_tools.xpath_filter(xpath_filter=filter_rule.replace('xpath:', ''),
+ html_content=self.fetcher.content,
+ append_pretty_line_formatting=not watch.is_source_type_url,
+ is_rss=is_rss)
+
+ elif filter_rule.startswith('xpath1:'):
+ html_content += html_tools.xpath1_filter(xpath_filter=filter_rule.replace('xpath1:', ''),
+ html_content=self.fetcher.content,
+ append_pretty_line_formatting=not watch.is_source_type_url,
+ is_rss=is_rss)
+ else:
+ html_content += html_tools.include_filters(include_filters=filter_rule,
+ html_content=self.fetcher.content,
+ append_pretty_line_formatting=not watch.is_source_type_url)
+
+ if not html_content.strip():
+ raise FilterNotFoundInResponse(msg=include_filters_rule, screenshot=self.fetcher.screenshot, xpath_data=self.fetcher.xpath_data)
+
+ if has_subtractive_selectors:
+ html_content = html_tools.element_removal(subtractive_selectors, html_content)
+
+ if watch.is_source_type_url:
+ stripped_text_from_html = html_content
+ else:
+ # extract text
+ do_anchor = self.datastore.data["settings"]["application"].get("render_anchor_tag_content", False)
+ stripped_text_from_html = html_tools.html_to_text(html_content=html_content,
+ render_anchor_tag_content=do_anchor,
+ is_rss=is_rss) # 1874 activate the 0:
+ regex_matched_output = []
+ for s_re in extract_text:
+ # incase they specified something in '/.../x'
+ if re.search(PERL_STYLE_REGEX, s_re, re.IGNORECASE):
+ regex = html_tools.perl_style_slash_enclosed_regex_to_options(s_re)
+ result = re.findall(regex, stripped_text_from_html)
+
+ for l in result:
+ if type(l) is tuple:
+ # @todo - some formatter option default (between groups)
+ regex_matched_output += list(l) + ['\n']
+ else:
+ # @todo - some formatter option default (between each ungrouped result)
+ regex_matched_output += [l] + ['\n']
+ else:
+ # Doesnt look like regex, just hunt for plaintext and return that which matches
+ # `stripped_text_from_html` will be bytes, so we must encode s_re also to bytes
+ r = re.compile(re.escape(s_re), re.IGNORECASE)
+ res = r.findall(stripped_text_from_html)
+ if res:
+ for match in res:
+ regex_matched_output += [match] + ['\n']
+
+ ##########################################################
+ stripped_text_from_html = ''
+
+ if regex_matched_output:
+ # @todo some formatter for presentation?
+ stripped_text_from_html = ''.join(regex_matched_output)
+
+ if watch.get('remove_duplicate_lines'):
+ stripped_text_from_html = '\n'.join(dict.fromkeys(line for line in stripped_text_from_html.replace("\n\n", "\n").splitlines()))
+
+
+ if watch.get('sort_text_alphabetically'):
+ # Note: Because a something
will add an extra line feed to signify the paragraph gap
+ # we end up with 'Some text\n\n', sorting will add all those extra \n at the start, so we remove them here.
+ stripped_text_from_html = stripped_text_from_html.replace("\n\n", "\n")
+ stripped_text_from_html = '\n'.join(sorted(stripped_text_from_html.splitlines(), key=lambda x: x.lower()))
+
+### CALCULATE MD5
+ # If there's text to ignore
+ text_to_ignore = watch.get('ignore_text', []) + self.datastore.data['settings']['application'].get('global_ignore_text', [])
+ text_to_ignore += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='ignore_text')
+
+ text_for_checksuming = stripped_text_from_html
+ if text_to_ignore:
+ text_for_checksuming = html_tools.strip_ignore_text(stripped_text_from_html, text_to_ignore)
+
+ # Re #133 - if we should strip whitespaces from triggering the change detected comparison
+ if text_for_checksuming and self.datastore.data['settings']['application'].get('ignore_whitespace', False):
+ fetched_md5 = hashlib.md5(text_for_checksuming.translate(TRANSLATE_WHITESPACE_TABLE).encode('utf-8')).hexdigest()
+ else:
+ fetched_md5 = hashlib.md5(text_for_checksuming.encode('utf-8')).hexdigest()
+
+ ############ Blocking rules, after checksum #################
+ blocked = False
+ trigger_text = watch.get('trigger_text', [])
+ trigger_text += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='trigger_text')
+ if len(trigger_text):
+ # Assume blocked
+ blocked = True
+ # Filter and trigger works the same, so reuse it
+ # It should return the line numbers that match
+ # Unblock flow if the trigger was found (some text remained after stripped what didnt match)
+ result = html_tools.strip_ignore_text(content=str(stripped_text_from_html),
+ wordlist=trigger_text,
+ mode="line numbers")
+ # Unblock if the trigger was found
+ if result:
+ blocked = False
+
+ text_should_not_be_present = watch.get('text_should_not_be_present', [])
+ text_should_not_be_present += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='text_should_not_be_present')
+ if len(text_should_not_be_present):
+ # If anything matched, then we should block a change from happening
+ result = html_tools.strip_ignore_text(content=str(stripped_text_from_html),
+ wordlist=text_should_not_be_present,
+ mode="line numbers")
+ if result:
+ blocked = True
+
+ # And check if 'conditions' will let this pass through
+ if watch.get('conditions') and watch.get('conditions_match_logic'):
+ conditions_result = execute_ruleset_against_all_plugins(current_watch_uuid=watch.get('uuid'),
+ application_datastruct=self.datastore.data,
+ ephemeral_data={
+ 'text': stripped_text_from_html
+ }
+ )
+
+ if not conditions_result.get('result'):
+ # Conditions say "Condition not met" so we block it.
+ blocked = True
+
+ # Looks like something changed, but did it match all the rules?
+ if blocked:
+ changed_detected = False
+ else:
+ # The main thing that all this at the moment comes down to :)
+ if watch.get('previous_md5') != fetched_md5:
+ changed_detected = True
+
+ # Always record the new checksum
+ update_obj["previous_md5"] = fetched_md5
+
+ # On the first run of a site, watch['previous_md5'] will be None, set it the current one.
+ if not watch.get('previous_md5'):
+ watch['previous_md5'] = fetched_md5
+
+ logger.debug(f"Watch UUID {watch.get('uuid')} content check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}")
+
+ if changed_detected:
+ if watch.get('check_unique_lines', False):
+ ignore_whitespace = self.datastore.data['settings']['application'].get('ignore_whitespace')
+
+ has_unique_lines = watch.lines_contain_something_unique_compared_to_history(
+ lines=stripped_text_from_html.splitlines(),
+ ignore_whitespace=ignore_whitespace
+ )
+
+ # One or more lines? unsure?
+ if not has_unique_lines:
+ logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} didnt have anything new setting change_detected=False")
+ changed_detected = False
+ else:
+ logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} had unique content")
+
+
+ # stripped_text_from_html - Everything after filters and NO 'ignored' content
+ return changed_detected, update_obj, stripped_text_from_html
diff --git a/changedetectionio/pytest.ini b/changedetectionio/pytest.ini
new file mode 100644
index 0000000000000000000000000000000000000000..af2b409a181708e0f09aee2ca5e83e18403f00c9
--- /dev/null
+++ b/changedetectionio/pytest.ini
@@ -0,0 +1,12 @@
+[pytest]
+addopts = --no-start-live-server --live-server-port=5005
+#testpaths = tests pytest_invenio
+#live_server_scope = function
+
+filterwarnings =
+ ignore::DeprecationWarning:urllib3.*:
+
+; logging options
+log_cli = 1
+log_cli_level = DEBUG
+log_cli_format = %(asctime)s %(name)s: %(levelname)s %(message)s
\ No newline at end of file
diff --git a/changedetectionio/queuedWatchMetaData.py b/changedetectionio/queuedWatchMetaData.py
new file mode 100644
index 0000000000000000000000000000000000000000..be388a78ef9e99a870f00c93c017898b9578042b
--- /dev/null
+++ b/changedetectionio/queuedWatchMetaData.py
@@ -0,0 +1,10 @@
+from dataclasses import dataclass, field
+from typing import Any
+
+# So that we can queue some metadata in `item`
+# https://docs.python.org/3/library/queue.html#queue.PriorityQueue
+#
+@dataclass(order=True)
+class PrioritizedItem:
+ priority: int
+ item: Any=field(compare=False)
diff --git a/changedetectionio/realtime/README.md b/changedetectionio/realtime/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..1bdde4825d228fdc640080aa52838b3d36611c7e
--- /dev/null
+++ b/changedetectionio/realtime/README.md
@@ -0,0 +1,141 @@
+# Real-time Socket.IO Implementation
+
+This directory contains the Socket.IO implementation for changedetection.io's real-time updates.
+
+## Architecture Overview
+
+The real-time system provides live updates to the web interface for:
+
+- Watch status changes (checking, completed, errors)
+- Queue length updates
+- General statistics updates
+
+## Current Implementation
+
+### Socket.IO Configuration
+
+- **Async Mode**: `threading` (default) or `gevent` (optional via SOCKETIO_MODE env var)
+- **Server**: Flask-SocketIO with threading support
+- **Background Tasks**: Python threading with daemon threads
+
+### Async Worker Integration
+
+- **Workers**: Async workers using asyncio for watch processing
+- **Queue**: AsyncSignalPriorityQueue for job distribution
+- **Signals**: Blinker signals for real-time updates between workers and Socket.IO
+
+### Environment Variables
+
+- `SOCKETIO_MODE=threading` (default, recommended)
+- `SOCKETIO_MODE=gevent` (optional, has cross-platform limitations)
+
+## Architecture Decision: Why Threading Mode?
+
+### Previous Issues with Eventlet
+
+**Eventlet was completely removed** due to fundamental compatibility issues:
+
+1. **Monkey Patching Conflicts**: `eventlet.monkey_patch()` globally replaced Python's threading/socket modules, causing conflicts with:
+
+ - Playwright's synchronous browser automation
+ - Async worker event loops
+ - Various Python libraries expecting real threading
+
+2. **Python 3.12+ Compatibility**: Eventlet had issues with newer Python versions and asyncio integration
+
+3. **CVE-2023-29483**: Security vulnerability in eventlet's dnspython dependency
+
+### Current Solution Benefits
+
+✅ **Threading Mode Advantages**:
+
+- Full compatibility with async workers and Playwright
+- No monkey patching - uses standard Python threading
+- Better Python 3.12+ support
+- Cross-platform compatibility (Windows, macOS, Linux)
+- No external async library dependencies
+- Fast shutdown capabilities
+
+✅ **Optional Gevent Support**:
+
+- Available via `SOCKETIO_MODE=gevent` for high-concurrency scenarios
+- Cross-platform limitations documented in requirements.txt
+- Not recommended as default due to Windows socket limits and macOS ARM build issues
+
+## Socket.IO Mode Configuration
+
+### Threading Mode (Default)
+
+```python
+# Enabled automatically
+async_mode = 'threading'
+socketio = SocketIO(app, async_mode='threading')
+```
+
+### Gevent Mode (Optional)
+
+```bash
+# Set environment variable
+export SOCKETIO_MODE=gevent
+```
+
+## Background Tasks
+
+### Queue Polling
+
+- **Threading Mode**: `threading.Thread` with `threading.Event` for shutdown
+- **Signal Handling**: Blinker signals for watch state changes
+- **Real-time Updates**: Direct Socket.IO `emit()` calls to connected clients
+
+### Worker Integration
+
+- **Async Workers**: Run in separate asyncio event loop thread
+- **Communication**: AsyncSignalPriorityQueue bridges async workers and Socket.IO
+- **Updates**: Real-time updates sent when workers complete tasks
+
+## Files in This Directory
+
+- `socket_server.py`: Main Socket.IO initialization and event handling
+- `events.py`: Watch operation event handlers
+- `__init__.py`: Module initialization
+
+## Production Deployment
+
+### Recommended WSGI Servers
+
+For production with Socket.IO threading mode:
+
+- **Gunicorn**: `gunicorn --worker-class eventlet changedetection:app` (if using gevent mode)
+- **uWSGI**: With threading support
+- **Docker**: Built-in Flask server works well for containerized deployments
+
+### Performance Considerations
+
+- Threading mode: Better memory usage, standard Python threading
+- Gevent mode: Higher concurrency but platform limitations
+- Async workers: Separate from Socket.IO, provides scalability
+
+## Environment Variables
+
+| Variable | Default | Description |
+| ---------------------- | ----------- | ---------------------------------------------- |
+| `SOCKETIO_MODE` | `threading` | Socket.IO async mode (`threading` or `gevent`) |
+| `FETCH_WORKERS` | `10` | Number of async workers for watch processing |
+| `CHANGEDETECTION_HOST` | `0.0.0.0` | Server bind address |
+| `CHANGEDETECTION_PORT` | `7860` | Server port |
+
+## Debugging Tips
+
+1. **Socket.IO Issues**: Check browser dev tools for WebSocket connection errors
+2. **Threading Issues**: Monitor with `ps -T` to check thread count
+3. **Worker Issues**: Use `/worker-health` endpoint to check async worker status
+4. **Queue Issues**: Use `/queue-status` endpoint to monitor job queue
+5. **Performance**: Use `/gc-cleanup` endpoint to trigger memory cleanup
+
+## Migration Notes
+
+If upgrading from eventlet-based versions:
+
+- Remove any `EVENTLET_*` environment variables
+- No code changes needed - Socket.IO mode is automatically configured
+- Optional: Set `SOCKETIO_MODE=gevent` if high concurrency is required and platform supports it
diff --git a/changedetectionio/realtime/__init__.py b/changedetectionio/realtime/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4ab6263e5b01ddc0de95b5bd98174fcce660f53c
--- /dev/null
+++ b/changedetectionio/realtime/__init__.py
@@ -0,0 +1,3 @@
+"""
+Socket.IO realtime updates module for changedetection.io
+"""
\ No newline at end of file
diff --git a/changedetectionio/realtime/events.py b/changedetectionio/realtime/events.py
new file mode 100644
index 0000000000000000000000000000000000000000..a68ea99cf13cda863ab178bce2d0044cd7dd685f
--- /dev/null
+++ b/changedetectionio/realtime/events.py
@@ -0,0 +1,58 @@
+from flask_socketio import emit
+from loguru import logger
+from blinker import signal
+
+
+def register_watch_operation_handlers(socketio, datastore):
+ """Register Socket.IO event handlers for watch operations"""
+
+ @socketio.on('watch_operation')
+ def handle_watch_operation(data):
+ """Handle watch operations like pause, mute, recheck via Socket.IO"""
+ try:
+ op = data.get('op')
+ uuid = data.get('uuid')
+
+ logger.debug(f"Socket.IO: Received watch operation '{op}' for UUID {uuid}")
+
+ if not op or not uuid:
+ emit('operation_result', {'success': False, 'error': 'Missing operation or UUID'})
+ return
+
+ # Check if watch exists
+ if not datastore.data['watching'].get(uuid):
+ emit('operation_result', {'success': False, 'error': 'Watch not found'})
+ return
+
+ watch = datastore.data['watching'][uuid]
+
+ # Perform the operation
+ if op == 'pause':
+ watch.toggle_pause()
+ logger.info(f"Socket.IO: Toggled pause for watch {uuid}")
+ elif op == 'mute':
+ watch.toggle_mute()
+ logger.info(f"Socket.IO: Toggled mute for watch {uuid}")
+ elif op == 'recheck':
+ # Import here to avoid circular imports
+ from changedetectionio.flask_app import update_q
+ from changedetectionio import queuedWatchMetaData
+ from changedetectionio import worker_handler
+
+ worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
+ logger.info(f"Socket.IO: Queued recheck for watch {uuid}")
+ else:
+ emit('operation_result', {'success': False, 'error': f'Unknown operation: {op}'})
+ return
+
+ # Send signal to update UI
+ watch_check_update = signal('watch_check_update')
+ if watch_check_update:
+ watch_check_update.send(watch_uuid=uuid)
+
+ # Send success response to client
+ emit('operation_result', {'success': True, 'operation': op, 'uuid': uuid})
+
+ except Exception as e:
+ logger.error(f"Socket.IO error in handle_watch_operation: {str(e)}")
+ emit('operation_result', {'success': False, 'error': str(e)})
diff --git a/changedetectionio/realtime/socket_server.py b/changedetectionio/realtime/socket_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..01082e35aeb526e3fdcfcaa39729460c12566c3a
--- /dev/null
+++ b/changedetectionio/realtime/socket_server.py
@@ -0,0 +1,396 @@
+import timeago
+from flask_socketio import SocketIO
+
+import time
+import os
+from loguru import logger
+from blinker import signal
+
+from changedetectionio import strtobool
+
+
+class SignalHandler:
+ """A standalone class to receive signals"""
+
+ def __init__(self, socketio_instance, datastore):
+ self.socketio_instance = socketio_instance
+ self.datastore = datastore
+
+ # Connect to the watch_check_update signal
+ from changedetectionio.flask_app import watch_check_update as wcc
+ wcc.connect(self.handle_signal, weak=False)
+ # logger.info("SignalHandler: Connected to signal from direct import")
+
+ # Connect to the queue_length signal
+ queue_length_signal = signal('queue_length')
+ queue_length_signal.connect(self.handle_queue_length, weak=False)
+ # logger.info("SignalHandler: Connected to queue_length signal")
+
+ watch_delete_signal = signal('watch_deleted')
+ watch_delete_signal.connect(self.handle_deleted_signal, weak=False)
+
+ # Connect to the notification_event signal
+ notification_event_signal = signal('notification_event')
+ notification_event_signal.connect(self.handle_notification_event, weak=False)
+ logger.info("SignalHandler: Connected to notification_event signal")
+
+ # Create and start the queue update thread using standard threading
+ import threading
+ self.polling_emitter_thread = threading.Thread(
+ target=self.polling_emit_running_or_queued_watches_threaded,
+ daemon=True
+ )
+ self.polling_emitter_thread.start()
+ logger.info("Started polling thread using threading (eventlet-free)")
+
+ # Store the thread reference in socketio for clean shutdown
+ self.socketio_instance.polling_emitter_thread = self.polling_emitter_thread
+
+ def handle_signal(self, *args, **kwargs):
+ logger.trace(f"SignalHandler: Signal received with {len(args)} args and {len(kwargs)} kwargs")
+ # Safely extract the watch UUID from kwargs
+ watch_uuid = kwargs.get('watch_uuid')
+ app_context = kwargs.get('app_context')
+
+ if watch_uuid:
+ # Get the watch object from the datastore
+ watch = self.datastore.data['watching'].get(watch_uuid)
+ if watch:
+ if app_context:
+ # note
+ with app_context.app_context():
+ with app_context.test_request_context():
+ # Forward to handle_watch_update with the watch parameter
+ handle_watch_update(self.socketio_instance, watch=watch, datastore=self.datastore)
+ else:
+ handle_watch_update(self.socketio_instance, watch=watch, datastore=self.datastore)
+
+ logger.trace(f"Signal handler processed watch UUID {watch_uuid}")
+ else:
+ logger.warning(f"Watch UUID {watch_uuid} not found in datastore")
+
+ def handle_deleted_signal(self, *args, **kwargs):
+ watch_uuid = kwargs.get('watch_uuid')
+ if watch_uuid:
+ # Emit the queue size to all connected clients
+ self.socketio_instance.emit("watch_deleted", {
+ "uuid": watch_uuid,
+ "event_timestamp": time.time()
+ })
+ logger.debug(f"Watch UUID {watch_uuid} was deleted")
+
+ def handle_queue_length(self, *args, **kwargs):
+ """Handle queue_length signal and emit to all clients"""
+ try:
+ queue_length = kwargs.get('length', 0)
+ logger.debug(f"SignalHandler: Queue length update received: {queue_length}")
+
+ # Emit the queue size to all connected clients
+ self.socketio_instance.emit("queue_size", {
+ "q_length": queue_length,
+ "event_timestamp": time.time()
+ })
+
+ except Exception as e:
+ logger.error(f"Socket.IO error in handle_queue_length: {str(e)}")
+
+ def handle_notification_event(self, *args, **kwargs):
+ """Handle notification_event signal and emit to all clients"""
+ try:
+ watch_uuid = kwargs.get('watch_uuid')
+ logger.debug(f"SignalHandler: Notification event received for watch UUID: {watch_uuid}")
+
+ # Emit the notification event to all connected clients
+ self.socketio_instance.emit("notification_event", {
+ "watch_uuid": watch_uuid,
+ "event_timestamp": time.time()
+ })
+
+ logger.trace(f"Socket.IO: Emitted notification_event for watch UUID {watch_uuid}")
+
+ except Exception as e:
+ logger.error(f"Socket.IO error in handle_notification_event: {str(e)}")
+
+
+ def polling_emit_running_or_queued_watches_threaded(self):
+ """Threading version of polling for Windows compatibility"""
+ import time
+ import threading
+ logger.info("Queue update thread started (threading mode)")
+
+ # Import here to avoid circular imports
+ from changedetectionio.flask_app import app
+ from changedetectionio import worker_handler
+ watch_check_update = signal('watch_check_update')
+
+ # Track previous state to avoid unnecessary emissions
+ previous_running_uuids = set()
+
+ # Run until app shutdown - check exit flag more frequently for fast shutdown
+ exit_event = getattr(app.config, 'exit', threading.Event())
+
+ while not exit_event.is_set():
+ try:
+ # Get current running UUIDs from async workers
+ running_uuids = set(worker_handler.get_running_uuids())
+
+ # Only send updates for UUIDs that changed state
+ newly_running = running_uuids - previous_running_uuids
+ no_longer_running = previous_running_uuids - running_uuids
+
+ # Send updates for newly running UUIDs (but exit fast if shutdown requested)
+ for uuid in newly_running:
+ if exit_event.is_set():
+ break
+ logger.trace(f"Threading polling: UUID {uuid} started processing")
+ with app.app_context():
+ watch_check_update.send(app_context=app, watch_uuid=uuid)
+ time.sleep(0.01) # Small yield
+
+ # Send updates for UUIDs that finished processing (but exit fast if shutdown requested)
+ if not exit_event.is_set():
+ for uuid in no_longer_running:
+ if exit_event.is_set():
+ break
+ logger.trace(f"Threading polling: UUID {uuid} finished processing")
+ with app.app_context():
+ watch_check_update.send(app_context=app, watch_uuid=uuid)
+ time.sleep(0.01) # Small yield
+
+ # Update tracking for next iteration
+ previous_running_uuids = running_uuids
+
+ # Sleep between polling cycles, but check exit flag every 0.5 seconds for fast shutdown
+ for _ in range(20): # 20 * 0.5 = 10 seconds total
+ if exit_event.is_set():
+ break
+ time.sleep(0.5)
+
+ except Exception as e:
+ logger.error(f"Error in threading polling: {str(e)}")
+ # Even during error recovery, check for exit quickly
+ for _ in range(1): # 1 * 0.5 = 0.5 seconds
+ if exit_event.is_set():
+ break
+ time.sleep(0.5)
+
+ # Check if we're in pytest environment - if so, be more gentle with logging
+ import sys
+ in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
+
+ if not in_pytest:
+ logger.info("Queue update thread stopped (threading mode)")
+
+
+def handle_watch_update(socketio, **kwargs):
+ """Handle watch update signal from blinker"""
+ try:
+ watch = kwargs.get('watch')
+ datastore = kwargs.get('datastore')
+
+ # Emit the watch update to all connected clients
+ from changedetectionio.flask_app import update_q
+ from changedetectionio.flask_app import _jinja2_filter_datetime
+ from changedetectionio import worker_handler
+
+ # Get list of watches that are currently running
+ running_uuids = worker_handler.get_running_uuids()
+
+ # Get list of watches in the queue
+ queue_list = []
+ for q_item in update_q.queue:
+ if hasattr(q_item, 'item') and 'uuid' in q_item.item:
+ queue_list.append(q_item.item['uuid'])
+
+ # Get the error texts from the watch
+ error_texts = watch.compile_error_texts()
+ # Create a simplified watch data object to send to clients
+
+ watch_data = {
+ 'checking_now': True if watch.get('uuid') in running_uuids else False,
+ 'fetch_time': watch.get('fetch_time'),
+ 'has_error': True if error_texts else False,
+ 'last_changed': watch.get('last_changed'),
+ 'last_checked': watch.get('last_checked'),
+ 'error_text': error_texts,
+ 'history_n': watch.history_n,
+ 'last_checked_text': _jinja2_filter_datetime(watch),
+ 'last_changed_text': timeago.format(int(watch.last_changed), time.time()) if watch.history_n >= 2 and int(watch.last_changed) > 0 else 'Not yet',
+ 'queued': True if watch.get('uuid') in queue_list else False,
+ 'paused': True if watch.get('paused') else False,
+ 'notification_muted': True if watch.get('notification_muted') else False,
+ 'unviewed': watch.has_unviewed,
+ 'uuid': watch.get('uuid'),
+ 'event_timestamp': time.time()
+ }
+
+ errored_count = 0
+ for watch_uuid_iter, watch_iter in datastore.data['watching'].items():
+ if watch_iter.get('last_error'):
+ errored_count += 1
+
+ general_stats = {
+ 'count_errors': errored_count,
+ 'has_unviewed': datastore.has_unviewed
+ }
+
+ # Debug what's being emitted
+ # logger.debug(f"Emitting 'watch_update' event for {watch.get('uuid')}, data: {watch_data}")
+
+ # Emit to all clients (no 'broadcast' parameter needed - it's the default behavior)
+ socketio.emit("watch_update", {'watch': watch_data, 'general_stats': general_stats})
+
+ # Log after successful emit - use watch_data['uuid'] to avoid variable shadowing issues
+ logger.trace(f"Socket.IO: Emitted update for watch {watch_data['uuid']}, Checking now: {watch_data['checking_now']}")
+
+ except Exception as e:
+ logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
+
+
+def init_socketio(app, datastore):
+ """Initialize SocketIO with the main Flask app"""
+ import platform
+ import sys
+
+ # Platform-specific async_mode selection for better stability
+ system = platform.system().lower()
+ python_version = sys.version_info
+
+ # Check for SocketIO mode configuration via environment variable
+ # Default is 'threading' for best cross-platform compatibility
+ socketio_mode = os.getenv('SOCKETIO_MODE', 'threading').lower()
+
+ if socketio_mode == 'gevent':
+ # Use gevent mode (higher concurrency but platform limitations)
+ try:
+ import gevent
+ async_mode = 'gevent'
+ logger.info(f"SOCKETIO_MODE=gevent: Using {async_mode} mode for Socket.IO")
+ except ImportError:
+ async_mode = 'threading'
+ logger.warning(f"SOCKETIO_MODE=gevent but gevent not available, falling back to {async_mode} mode")
+ elif socketio_mode == 'threading':
+ # Use threading mode (default - best compatibility)
+ async_mode = 'threading'
+ logger.info(f"SOCKETIO_MODE=threading: Using {async_mode} mode for Socket.IO")
+ else:
+ # Invalid mode specified, use default
+ async_mode = 'threading'
+ logger.warning(f"Invalid SOCKETIO_MODE='{socketio_mode}', using default {async_mode} mode for Socket.IO")
+
+ # Log platform info for debugging
+ logger.info(f"Platform: {system}, Python: {python_version.major}.{python_version.minor}, Socket.IO mode: {async_mode}")
+
+ # Restrict SocketIO CORS to same origin by default, can be overridden with env var
+ cors_origins = os.environ.get('SOCKETIO_CORS_ORIGINS', None)
+
+ socketio = SocketIO(app,
+ async_mode=async_mode,
+ cors_allowed_origins=cors_origins, # None means same-origin only
+ logger=strtobool(os.getenv('SOCKETIO_LOGGING', 'False')),
+ engineio_logger=strtobool(os.getenv('SOCKETIO_LOGGING', 'False')))
+
+ # Set up event handlers
+ logger.info("Socket.IO: Registering connect event handler")
+
+ @socketio.on('checkbox-operation')
+ def event_checkbox_operations(data):
+ from changedetectionio.blueprint.ui import _handle_operations
+ from changedetectionio import queuedWatchMetaData
+ from changedetectionio import worker_handler
+ from changedetectionio.flask_app import update_q, watch_check_update
+ logger.trace(f"Got checkbox operations event: {data}")
+
+ datastore = socketio.datastore
+
+ _handle_operations(
+ op=data.get('op'),
+ uuids=data.get('uuids'),
+ datastore=datastore,
+ extra_data=data.get('extra_data'),
+ worker_handler=worker_handler,
+ update_q=update_q,
+ queuedWatchMetaData=queuedWatchMetaData,
+ watch_check_update=watch_check_update,
+ emit_flash=False
+ )
+
+
+ @socketio.on('connect')
+ def handle_connect():
+ """Handle client connection"""
+ # logger.info("Socket.IO: CONNECT HANDLER CALLED - Starting connection process")
+ from flask import request
+ from flask_login import current_user
+ from changedetectionio.flask_app import update_q
+
+ # Access datastore from socketio
+ datastore = socketio.datastore
+ # logger.info(f"Socket.IO: Current user authenticated: {current_user.is_authenticated if hasattr(current_user, 'is_authenticated') else 'No current_user'}")
+
+ # Check if authentication is required and user is not authenticated
+ has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False)
+ # logger.info(f"Socket.IO: Password enabled: {has_password_enabled}")
+ if has_password_enabled and not current_user.is_authenticated:
+ logger.warning("Socket.IO: Rejecting unauthenticated connection")
+ return False # Reject the connection
+
+ # Send the current queue size to the newly connected client
+ try:
+ queue_size = update_q.qsize()
+ socketio.emit("queue_size", {
+ "q_length": queue_size,
+ "event_timestamp": time.time()
+ }, room=request.sid) # Send only to this client
+ logger.debug(f"Socket.IO: Sent initial queue size {queue_size} to new client")
+ except Exception as e:
+ logger.error(f"Socket.IO error sending initial queue size: {str(e)}")
+
+ logger.info("Socket.IO: Client connected")
+
+ # logger.info("Socket.IO: Registering disconnect event handler")
+ @socketio.on('disconnect')
+ def handle_disconnect():
+ """Handle client disconnection"""
+ logger.info("Socket.IO: Client disconnected")
+
+ # Create a dedicated signal handler that will receive signals and emit them to clients
+ signal_handler = SignalHandler(socketio, datastore)
+
+ # Register watch operation event handlers
+ from .events import register_watch_operation_handlers
+ register_watch_operation_handlers(socketio, datastore)
+
+ # Store the datastore reference on the socketio object for later use
+ socketio.datastore = datastore
+
+ # No stop event needed for threading mode - threads check app.config.exit directly
+
+ # Add a shutdown method to the socketio object
+ def shutdown():
+ """Shutdown the SocketIO server fast and aggressively"""
+ try:
+ logger.info("Socket.IO: Fast shutdown initiated...")
+
+ # For threading mode, give the thread a very short time to exit gracefully
+ if hasattr(socketio, 'polling_emitter_thread'):
+ if socketio.polling_emitter_thread.is_alive():
+ logger.info("Socket.IO: Waiting 1 second for polling thread to stop...")
+ socketio.polling_emitter_thread.join(timeout=1.0) # Only 1 second timeout
+ if socketio.polling_emitter_thread.is_alive():
+ logger.info("Socket.IO: Polling thread still running after timeout - continuing with shutdown")
+ else:
+ logger.info("Socket.IO: Polling thread stopped quickly")
+ else:
+ logger.info("Socket.IO: Polling thread already stopped")
+
+ logger.info("Socket.IO: Fast shutdown complete")
+ except Exception as e:
+ logger.error(f"Socket.IO error during shutdown: {str(e)}")
+
+ # Attach the shutdown method to the socketio object
+ socketio.shutdown = shutdown
+
+ logger.info("Socket.IO initialized and attached to main Flask app")
+ logger.info(f"Socket.IO: Registered event handlers: {socketio.handlers if hasattr(socketio, 'handlers') else 'No handlers found'}")
+ return socketio
diff --git a/changedetectionio/run_basic_tests.sh b/changedetectionio/run_basic_tests.sh
new file mode 100644
index 0000000000000000000000000000000000000000..2f179fda1a3100724764b99ded01bcd89f1daf70
--- /dev/null
+++ b/changedetectionio/run_basic_tests.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+
+
+# live_server will throw errors even with live_server_scope=function if I have the live_server setup in different functions
+# and I like to restart the server for each test (and have the test cleanup after each test)
+# merge request welcome :)
+
+
+# exit when any command fails
+set -e
+
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+
+find tests/test_*py -type f|while read test_name
+do
+ echo "TEST RUNNING $test_name"
+ # REMOVE_REQUESTS_OLD_SCREENSHOTS disabled so that we can write a screenshot and send it in test_notifications.py without a real browser
+ REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest $test_name
+done
+
+echo "RUNNING WITH BASE_URL SET"
+
+# Now re-run some tests with BASE_URL enabled
+# Re #65 - Ability to include a link back to the installation, in the notification.
+export BASE_URL="https://really-unique-domain.io"
+REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py
+
+
+# Re-run with HIDE_REFERER set - could affect login
+export HIDE_REFERER=True
+pytest tests/test_access_control.py
+
+# Re-run a few tests that will trigger brotli based storage
+export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5
+pytest tests/test_access_control.py
+REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py
+pytest tests/test_backend.py
+pytest tests/test_rss.py
+pytest tests/test_unique_lines.py
+
+# Try high concurrency
+FETCH_WORKERS=130 pytest tests/test_history_consistency.py -v -l
+
+# Check file:// will pickup a file when enabled
+echo "Hello world" > /tmp/test-file.txt
+ALLOW_FILE_URI=yes pytest tests/test_security.py
diff --git a/changedetectionio/run_custom_browser_url_tests.sh b/changedetectionio/run_custom_browser_url_tests.sh
new file mode 100644
index 0000000000000000000000000000000000000000..dc91d498d791ebb3ab033f453400645bd92dd87a
--- /dev/null
+++ b/changedetectionio/run_custom_browser_url_tests.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+
+# run some tests and look if the 'custom-browser-search-string=1' connect string appeared in the correct containers
+
+# @todo do it again but with the puppeteer one
+
+# enable debug
+set -x
+
+# A extra browser is configured, but we never chose to use it, so it should NOT show in the logs
+docker run --rm -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/custom_browser_url/test_custom_browser_url.py::test_request_not_via_custom_browser_url'
+docker logs sockpuppetbrowser-custom-url &>log-custom.txt
+grep 'custom-browser-search-string=1' log-custom.txt
+if [ $? -ne 1 ]
+then
+ echo "Saw a request in 'sockpuppetbrowser-custom-url' container with 'custom-browser-search-string=1' when I should not - log-custom.txt"
+ exit 1
+fi
+
+docker logs sockpuppetbrowser &>log.txt
+grep 'custom-browser-search-string=1' log.txt
+if [ $? -ne 1 ]
+then
+ echo "Saw a request in 'browser' container with 'custom-browser-search-string=1' when I should not"
+ exit 1
+fi
+
+# Special connect string should appear in the custom-url container, but not in the 'default' one
+docker run --rm -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/custom_browser_url/test_custom_browser_url.py::test_request_via_custom_browser_url'
+docker logs sockpuppetbrowser-custom-url &>log-custom.txt
+grep 'custom-browser-search-string=1' log-custom.txt
+if [ $? -ne 0 ]
+then
+ echo "Did not see request in 'sockpuppetbrowser-custom-url' container with 'custom-browser-search-string=1' when I should - log-custom.txt"
+ exit 1
+fi
+
+docker logs sockpuppetbrowser &>log.txt
+grep 'custom-browser-search-string=1' log.txt
+if [ $? -ne 1 ]
+then
+ echo "Saw a request in 'browser' container with 'custom-browser-search-string=1' when I should not"
+ exit 1
+fi
+
+
diff --git a/changedetectionio/run_proxy_tests.sh b/changedetectionio/run_proxy_tests.sh
new file mode 100644
index 0000000000000000000000000000000000000000..3e029dec32b1314eb19724924c70f0da7d1059c4
--- /dev/null
+++ b/changedetectionio/run_proxy_tests.sh
@@ -0,0 +1,106 @@
+#!/bin/bash
+
+# exit when any command fails
+set -e
+# enable debug
+set -x
+
+# Test proxy list handling, starting two squids on different ports
+# Each squid adds a different header to the response, which is the main thing we test for.
+docker run --network changedet-network -d --name squid-one --hostname squid-one --rm -v `pwd`/tests/proxy_list/squid.conf:/etc/squid/conf.d/debian.conf ubuntu/squid:4.13-21.10_edge
+docker run --network changedet-network -d --name squid-two --hostname squid-two --rm -v `pwd`/tests/proxy_list/squid.conf:/etc/squid/conf.d/debian.conf ubuntu/squid:4.13-21.10_edge
+
+# Used for configuring a custom proxy URL via the UI - with username+password auth
+docker run --network changedet-network -d \
+ --name squid-custom \
+ --hostname squid-custom \
+ --rm \
+ -v `pwd`/tests/proxy_list/squid-auth.conf:/etc/squid/conf.d/debian.conf \
+ -v `pwd`/tests/proxy_list/squid-passwords.txt:/etc/squid3/passwords \
+ ubuntu/squid:4.13-21.10_edge
+
+
+## 2nd test actually choose the preferred proxy from proxies.json
+docker run --network changedet-network \
+ -v `pwd`/tests/proxy_list/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json \
+ test-changedetectionio \
+ bash -c 'cd changedetectionio && pytest tests/proxy_list/test_multiple_proxy.py'
+
+set +e
+echo "- Looking for chosen.changedetection.io request in squid-one - it should NOT be here"
+docker logs squid-one 2>/dev/null|grep chosen.changedetection.io
+if [ $? -ne 1 ]
+then
+ echo "Saw a request to chosen.changedetection.io in the squid logs (while checking preferred proxy - squid one) WHEN I SHOULD NOT"
+ exit 1
+fi
+
+set -e
+echo "- Looking for chosen.changedetection.io request in squid-two"
+# And one in the 'second' squid (user selects this as preferred)
+docker logs squid-two 2>/dev/null|grep chosen.changedetection.io
+if [ $? -ne 0 ]
+then
+ echo "Did not see a request to chosen.changedetection.io in the squid logs (while checking preferred proxy - squid two)"
+ exit 1
+fi
+
+# Test the UI configurable proxies
+docker run --network changedet-network \
+ test-changedetectionio \
+ bash -c 'cd changedetectionio && pytest tests/proxy_list/test_select_custom_proxy.py'
+
+
+# Should see a request for one.changedetection.io in there
+echo "- Looking for .changedetection.io request in squid-custom"
+docker logs squid-custom 2>/dev/null|grep "TCP_TUNNEL.200.*changedetection.io"
+if [ $? -ne 0 ]
+then
+ echo "Did not see a valid request to changedetection.io in the squid logs (while checking preferred proxy - squid two)"
+ exit 1
+fi
+
+# Test "no-proxy" option
+docker run --network changedet-network \
+ test-changedetectionio \
+ bash -c 'cd changedetectionio && pytest tests/proxy_list/test_noproxy.py'
+
+# We need to handle grep returning 1
+set +e
+# Check request was never seen in any container
+for c in $(echo "squid-one squid-two squid-custom"); do
+ echo ....Checking $c
+ docker logs $c &> $c.txt
+ grep noproxy $c.txt
+ if [ $? -ne 1 ]
+ then
+ echo "Saw request for noproxy in $c container"
+ cat $c.txt
+ exit 1
+ fi
+done
+
+
+docker kill squid-one squid-two squid-custom
+
+# Test that the UI is returning the correct error message when a proxy is not available
+
+# Requests
+docker run --network changedet-network \
+ test-changedetectionio \
+ bash -c 'cd changedetectionio && pytest tests/proxy_list/test_proxy_noconnect.py'
+
+# Playwright
+docker run --network changedet-network \
+ test-changedetectionio \
+ bash -c 'cd changedetectionio && PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000 pytest tests/proxy_list/test_proxy_noconnect.py'
+
+# Puppeteer fast
+docker run --network changedet-network \
+ test-changedetectionio \
+ bash -c 'cd changedetectionio && FAST_PUPPETEER_CHROME_FETCHER=1 PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000 pytest tests/proxy_list/test_proxy_noconnect.py'
+
+# Selenium
+docker run --network changedet-network \
+ test-changedetectionio \
+ bash -c 'cd changedetectionio && WEBDRIVER_URL=http://selenium:4444/wd/hub pytest tests/proxy_list/test_proxy_noconnect.py'
diff --git a/changedetectionio/run_socks_proxy_tests.sh b/changedetectionio/run_socks_proxy_tests.sh
new file mode 100644
index 0000000000000000000000000000000000000000..6db4bb4110fbcbb058f36e7ebb3b3905ce7e7939
--- /dev/null
+++ b/changedetectionio/run_socks_proxy_tests.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+
+# exit when any command fails
+set -e
+# enable debug
+set -x
+
+
+# SOCKS5 related - start simple Socks5 proxy server
+# SOCKSTEST=xyz should show in the logs of this service to confirm it fetched
+docker run --network changedet-network -d --hostname socks5proxy --rm --name socks5proxy -p 1080:1080 -e PROXY_USER=proxy_user123 -e PROXY_PASSWORD=proxy_pass123 serjs/go-socks5-proxy
+docker run --network changedet-network -d --hostname socks5proxy-noauth --rm -p 1081:1080 --name socks5proxy-noauth serjs/go-socks5-proxy
+
+echo "---------------------------------- SOCKS5 -------------------"
+# SOCKS5 related - test from proxies.json
+docker run --network changedet-network \
+ -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json \
+ --rm \
+ -e "FLASK_SERVER_NAME=cdio" \
+ --hostname cdio \
+ -e "SOCKSTEST=proxiesjson" \
+ test-changedetectionio \
+ bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004 -s tests/proxy_socks5/test_socks5_proxy_sources.py'
+
+# SOCKS5 related - by manually entering in UI
+docker run --network changedet-network \
+ --rm \
+ -e "FLASK_SERVER_NAME=cdio" \
+ --hostname cdio \
+ -e "SOCKSTEST=manual" \
+ test-changedetectionio \
+ bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004 -s tests/proxy_socks5/test_socks5_proxy.py'
+
+# SOCKS5 related - test from proxies.json via playwright - NOTE- PLAYWRIGHT DOESNT SUPPORT AUTHENTICATING PROXY
+docker run --network changedet-network \
+ -e "SOCKSTEST=manual-playwright" \
+ --hostname cdio \
+ -e "FLASK_SERVER_NAME=cdio" \
+ -v `pwd`/tests/proxy_socks5/proxies.json-example-noauth:/app/changedetectionio/test-datastore/proxies.json \
+ -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" \
+ --rm \
+ test-changedetectionio \
+ bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004 -s tests/proxy_socks5/test_socks5_proxy_sources.py'
+
+echo "socks5 server logs"
+docker logs socks5proxy
+echo "----------------------------------"
+
+docker kill socks5proxy socks5proxy-noauth
diff --git a/changedetectionio/safe_jinja.py b/changedetectionio/safe_jinja.py
new file mode 100644
index 0000000000000000000000000000000000000000..8a6e1d38e284898f9c61c6cd87dd44e495e908bf
--- /dev/null
+++ b/changedetectionio/safe_jinja.py
@@ -0,0 +1,18 @@
+"""
+Safe Jinja2 render with max payload sizes
+
+See https://jinja.palletsprojects.com/en/3.1.x/sandbox/#security-considerations
+"""
+
+import jinja2.sandbox
+import typing as t
+import os
+
+JINJA2_MAX_RETURN_PAYLOAD_SIZE = 1024 * int(os.getenv("JINJA2_MAX_RETURN_PAYLOAD_SIZE_KB", 1024 * 10))
+
+
+def render(template_str, **args: t.Any) -> str:
+ jinja2_env = jinja2.sandbox.ImmutableSandboxedEnvironment(extensions=['jinja2_time.TimeExtension'])
+ output = jinja2_env.from_string(template_str).render(args)
+ return output[:JINJA2_MAX_RETURN_PAYLOAD_SIZE]
+
diff --git a/changedetectionio/static/favicons/android-chrome-192x192.png b/changedetectionio/static/favicons/android-chrome-192x192.png
new file mode 100644
index 0000000000000000000000000000000000000000..3fc5b822c49044f6885bb537345fba9c9c98178b
Binary files /dev/null and b/changedetectionio/static/favicons/android-chrome-192x192.png differ
diff --git a/changedetectionio/static/favicons/android-chrome-256x256.png b/changedetectionio/static/favicons/android-chrome-256x256.png
new file mode 100644
index 0000000000000000000000000000000000000000..e0a2b08216b167520a77daf6346c04d144e20c07
Binary files /dev/null and b/changedetectionio/static/favicons/android-chrome-256x256.png differ
diff --git a/changedetectionio/static/favicons/apple-touch-icon.png b/changedetectionio/static/favicons/apple-touch-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..8f6b4a797b51460d4a4bdbb4bee7a4d38829f999
Binary files /dev/null and b/changedetectionio/static/favicons/apple-touch-icon.png differ
diff --git a/changedetectionio/static/favicons/browserconfig.xml b/changedetectionio/static/favicons/browserconfig.xml
new file mode 100644
index 0000000000000000000000000000000000000000..02d40fa087841311b95c85d7b9e2e25dfae9140f
--- /dev/null
+++ b/changedetectionio/static/favicons/browserconfig.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+ #da532c
+
+
+
diff --git a/changedetectionio/static/favicons/favicon-16x16.png b/changedetectionio/static/favicons/favicon-16x16.png
new file mode 100644
index 0000000000000000000000000000000000000000..053f17e536a1601e94c5f55960ad5128854d320f
Binary files /dev/null and b/changedetectionio/static/favicons/favicon-16x16.png differ
diff --git a/changedetectionio/static/favicons/favicon-32x32.png b/changedetectionio/static/favicons/favicon-32x32.png
new file mode 100644
index 0000000000000000000000000000000000000000..4438173b3a8683ebdef4f149f8de7a9a73a03911
Binary files /dev/null and b/changedetectionio/static/favicons/favicon-32x32.png differ
diff --git a/changedetectionio/static/favicons/favicon.ico b/changedetectionio/static/favicons/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..1b358c608b7459fc79689e7a6fe73d9ebd2670d2
Binary files /dev/null and b/changedetectionio/static/favicons/favicon.ico differ
diff --git a/changedetectionio/static/favicons/mstile-150x150.png b/changedetectionio/static/favicons/mstile-150x150.png
new file mode 100644
index 0000000000000000000000000000000000000000..2e201f9ac665d9eefa806e43f3ee698bbda2877f
Binary files /dev/null and b/changedetectionio/static/favicons/mstile-150x150.png differ
diff --git a/changedetectionio/static/favicons/safari-pinned-tab.svg b/changedetectionio/static/favicons/safari-pinned-tab.svg
new file mode 100644
index 0000000000000000000000000000000000000000..648b5c54268fe9b459f8f07184fdc37b4c2b082a
--- /dev/null
+++ b/changedetectionio/static/favicons/safari-pinned-tab.svg
@@ -0,0 +1,35 @@
+
+
+
+
+Created by potrace 1.14, written by Peter Selinger 2001-2017
+
+
+
+
+
+
diff --git a/changedetectionio/static/favicons/site.webmanifest b/changedetectionio/static/favicons/site.webmanifest
new file mode 100644
index 0000000000000000000000000000000000000000..6d25c20e0fb27c3ff1d0f45a1c59dd983f2b501a
--- /dev/null
+++ b/changedetectionio/static/favicons/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "",
+ "short_name": "",
+ "icons": [
+ {
+ "src": "android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "android-chrome-256x256.png",
+ "sizes": "256x256",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/changedetectionio/static/images/avatar-256x256.png b/changedetectionio/static/images/avatar-256x256.png
new file mode 100644
index 0000000000000000000000000000000000000000..f9ad497a2e4b1799b1f4c26bb5554a79baf180f2
Binary files /dev/null and b/changedetectionio/static/images/avatar-256x256.png differ
diff --git a/changedetectionio/static/images/bell-off.svg b/changedetectionio/static/images/bell-off.svg
new file mode 100644
index 0000000000000000000000000000000000000000..e2447987150752ed88a68b25d770dd092c982b86
--- /dev/null
+++ b/changedetectionio/static/images/bell-off.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/changedetectionio/static/images/beta-logo.png b/changedetectionio/static/images/beta-logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..e5533ee23315e360d9a29e36c3e8db28b01ef601
Binary files /dev/null and b/changedetectionio/static/images/beta-logo.png differ
diff --git a/changedetectionio/static/images/brightdata.svg b/changedetectionio/static/images/brightdata.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5ac7e2bb3e0ae0e85be5c83fcfb8cd065bc162cd
--- /dev/null
+++ b/changedetectionio/static/images/brightdata.svg
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/changedetectionio/static/images/copy.svg b/changedetectionio/static/images/copy.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b2758f6082c60e88b542fcd9e544c9887bd47bab
--- /dev/null
+++ b/changedetectionio/static/images/copy.svg
@@ -0,0 +1,40 @@
+
+
diff --git a/changedetectionio/static/images/email.svg b/changedetectionio/static/images/email.svg
new file mode 100644
index 0000000000000000000000000000000000000000..63a712114e31dcf5424e453e72d2241b0ab57865
--- /dev/null
+++ b/changedetectionio/static/images/email.svg
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/changedetectionio/static/images/generic-icon.svg b/changedetectionio/static/images/generic-icon.svg
new file mode 100644
index 0000000000000000000000000000000000000000..668ccb2113af98109662d9fada3cf99bd1811a22
--- /dev/null
+++ b/changedetectionio/static/images/generic-icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/changedetectionio/static/images/generic_feed-icon.svg b/changedetectionio/static/images/generic_feed-icon.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a7f9cf196c5ad44579aeb152dc4b56b4b75c4c95
--- /dev/null
+++ b/changedetectionio/static/images/generic_feed-icon.svg
@@ -0,0 +1,18 @@
+
+
\ No newline at end of file
diff --git a/changedetectionio/static/images/google-chrome-icon.png b/changedetectionio/static/images/google-chrome-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..a4dd10f5aa6e4753ac6fb723fa3fa006624e5c4d
Binary files /dev/null and b/changedetectionio/static/images/google-chrome-icon.png differ
diff --git a/changedetectionio/static/images/notice.svg b/changedetectionio/static/images/notice.svg
new file mode 100644
index 0000000000000000000000000000000000000000..195cbba32b9e4e483aa12927d8e4ff90e9813767
--- /dev/null
+++ b/changedetectionio/static/images/notice.svg
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
diff --git a/changedetectionio/static/images/oxylabs.svg b/changedetectionio/static/images/oxylabs.svg
new file mode 100644
index 0000000000000000000000000000000000000000..3c0623414474d26e4923086763fea84d88220014
--- /dev/null
+++ b/changedetectionio/static/images/oxylabs.svg
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/changedetectionio/static/images/pause.svg b/changedetectionio/static/images/pause.svg
new file mode 100644
index 0000000000000000000000000000000000000000..021c85d4bb42ea3b0024892ea8147866903b8618
--- /dev/null
+++ b/changedetectionio/static/images/pause.svg
@@ -0,0 +1,83 @@
+
+image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/changedetectionio/static/images/pdf-icon.svg b/changedetectionio/static/images/pdf-icon.svg
new file mode 100644
index 0000000000000000000000000000000000000000..07c96f04bf56166969842b94d6c61abd7745eb53
--- /dev/null
+++ b/changedetectionio/static/images/pdf-icon.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/changedetectionio/static/images/play.svg b/changedetectionio/static/images/play.svg
new file mode 100644
index 0000000000000000000000000000000000000000..45ec8a81f0ca5340569c92a3b0828ab05ac34ee5
--- /dev/null
+++ b/changedetectionio/static/images/play.svg
@@ -0,0 +1,121 @@
+
+image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/changedetectionio/static/images/playwright-icon.png b/changedetectionio/static/images/playwright-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..75db893b6a8d93e7225759f894e2a3ae3de090f8
Binary files /dev/null and b/changedetectionio/static/images/playwright-icon.png differ
diff --git a/changedetectionio/static/images/price-tag-icon.svg b/changedetectionio/static/images/price-tag-icon.svg
new file mode 100644
index 0000000000000000000000000000000000000000..f58b1c173bf3e1a3986cb85eac9e32724f46f5ef
--- /dev/null
+++ b/changedetectionio/static/images/price-tag-icon.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/changedetectionio/static/images/schedule.svg b/changedetectionio/static/images/schedule.svg
new file mode 100644
index 0000000000000000000000000000000000000000..23d329debdd956088284176d17770d2ab5de2903
--- /dev/null
+++ b/changedetectionio/static/images/schedule.svg
@@ -0,0 +1,225 @@
+
+
diff --git a/changedetectionio/static/images/spread-white.svg b/changedetectionio/static/images/spread-white.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6d1f7f827f3cda9da8306e957f4f5820de60a6d4
--- /dev/null
+++ b/changedetectionio/static/images/spread-white.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/changedetectionio/static/images/spread.svg b/changedetectionio/static/images/spread.svg
new file mode 100644
index 0000000000000000000000000000000000000000..860b280639dc19c58be2f78ffffdfea6521608b4
--- /dev/null
+++ b/changedetectionio/static/images/spread.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/changedetectionio/static/images/steps.svg b/changedetectionio/static/images/steps.svg
new file mode 100644
index 0000000000000000000000000000000000000000..57d95c41ff771d3e5a480d6396ae883cd4b155bf
--- /dev/null
+++ b/changedetectionio/static/images/steps.svg
@@ -0,0 +1,44 @@
+
+
+
+
+
+
diff --git a/changedetectionio/static/js/browser-steps.js b/changedetectionio/static/js/browser-steps.js
new file mode 100644
index 0000000000000000000000000000000000000000..8b069005abd7d3d58bb0457fffe7d9b8827de9fe
--- /dev/null
+++ b/changedetectionio/static/js/browser-steps.js
@@ -0,0 +1,478 @@
+$(document).ready(function () {
+
+ var browsersteps_session_id;
+ var browser_interface_seconds_remaining = 0;
+ var apply_buttons_disabled = false;
+ var include_text_elements = $("#include_text_elements");
+ var xpath_data = false;
+ var current_selected_i;
+ var state_clicked = false;
+ var c;
+
+ // redline highlight context
+ var ctx;
+ var last_click_xy = {'x': -1, 'y': -1}
+
+ $(window).resize(function () {
+ set_scale();
+ });
+ // Should always be disabled
+ $('#browser_steps-0-operation option[value="Goto site"]').prop("selected", "selected");
+ $('#browser_steps-0-operation').attr('disabled', 'disabled');
+
+ $('#browsersteps-click-start').click(function () {
+ $("#browsersteps-click-start").fadeOut();
+ $("#browsersteps-selector-wrapper .spinner").fadeIn();
+ start();
+ });
+
+ $('a#browsersteps-tab').click(function () {
+ reset();
+ });
+
+ window.addEventListener('hashchange', function () {
+ if (window.location.hash == '#browser-steps') {
+ reset();
+ }
+ });
+
+ function reset() {
+ xpath_data = false;
+ $('#browsersteps-img').removeAttr('src');
+ $("#browsersteps-click-start").show();
+ $("#browsersteps-selector-wrapper .spinner").hide();
+ browser_interface_seconds_remaining = 0;
+ browsersteps_session_id = false;
+ apply_buttons_disabled = false;
+ ctx.clearRect(0, 0, c.width, c.height);
+ set_first_gotosite_disabled();
+ }
+
+ function set_first_gotosite_disabled() {
+ $('#browser_steps >li:first-child select').val('Goto site').attr('disabled', 'disabled');
+ $('#browser_steps >li:first-child').css('opacity', '0.5');
+ }
+
+ // Show seconds remaining until the browser interface needs to restart the session
+ // (See comment at the top of changedetectionio/blueprint/browser_steps/__init__.py )
+ setInterval(() => {
+ if (browser_interface_seconds_remaining >= 1) {
+ document.getElementById('browser-seconds-remaining').innerText = browser_interface_seconds_remaining + " seconds remaining in session";
+ browser_interface_seconds_remaining -= 1;
+ }
+ }, "1000")
+
+
+ function set_scale() {
+
+ // some things to check if the scaling doesnt work
+ // - that the widths/sizes really are about the actual screen size cat elements.json |grep -o width......|sort|uniq
+ selector_image = $("img#browsersteps-img")[0];
+ selector_image_rect = selector_image.getBoundingClientRect();
+
+ // make the canvas and input steps the same size as the image
+ $('#browsersteps-selector-canvas').attr('height', selector_image_rect.height).attr('width', selector_image_rect.width);
+ //$('#browsersteps-selector-wrapper').attr('width', selector_image_rect.width);
+ $('#browser-steps-ui').attr('width', selector_image_rect.width);
+
+ x_scale = selector_image_rect.width / xpath_data['browser_width'];
+ y_scale = selector_image_rect.height / selector_image.naturalHeight;
+ ctx.strokeStyle = 'rgba(255,0,0, 0.9)';
+ ctx.fillStyle = 'rgba(255,0,0, 0.1)';
+ ctx.lineWidth = 3;
+ console.log("scaling set x: " + x_scale + " by y:" + y_scale);
+ }
+
+ // bootstrap it, this will trigger everything else
+ $('#browsersteps-img').bind('load', function () {
+ $('body').addClass('full-width');
+ console.log("Loaded background...");
+
+ document.getElementById("browsersteps-selector-canvas");
+ c = document.getElementById("browsersteps-selector-canvas");
+ // redline highlight context
+ ctx = c.getContext("2d");
+ // @todo is click better?
+ $('#browsersteps-selector-canvas').off("mousemove mousedown click");
+ // Undo disable_browsersteps_ui
+ $("#browser-steps-ui").css('opacity', '1.0');
+
+ // init
+ set_scale();
+
+ // @todo click ? some better library?
+ $('#browsersteps-selector-canvas').bind('click', function (e) {
+ // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent
+ e.preventDefault()
+ });
+
+ // When the mouse moves we know which element it should be above
+ // mousedown will link that to the UI (select the right action, highlight etc)
+ $('#browsersteps-selector-canvas').bind('mousedown', function (e) {
+ // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent
+ e.preventDefault()
+ last_click_xy = {'x': parseInt((1 / x_scale) * e.offsetX), 'y': parseInt((1 / y_scale) * e.offsetY)}
+ process_selected(current_selected_i);
+ current_selected_i = false;
+
+ // if process selected returned false, then best we can do is offer a x,y click :(
+ if (!found_something) {
+ var first_available = $("ul#browser_steps li.empty").first();
+ $('select', first_available).val('Click X,Y').change();
+ $('input[type=text]', first_available).first().val(last_click_xy['x'] + ',' + last_click_xy['y']);
+ draw_circle_on_canvas(e.offsetX, e.offsetY);
+ }
+ });
+
+ // Debounce and find the current most 'interesting' element we are hovering above
+ $('#browsersteps-selector-canvas').bind('mousemove', function (e) {
+ if (!xpath_data) {
+ return;
+ }
+
+ // checkbox if find elements is enabled
+ ctx.clearRect(0, 0, c.width, c.height);
+ ctx.fillStyle = 'rgba(255,0,0, 0.1)';
+ ctx.strokeStyle = 'rgba(255,0,0, 0.9)';
+
+ // Add in offset
+ if ((typeof e.offsetX === "undefined" || typeof e.offsetY === "undefined") || (e.offsetX === 0 && e.offsetY === 0)) {
+ var targetOffset = $(e.target).offset();
+ e.offsetX = e.pageX - targetOffset.left;
+ e.offsetY = e.pageY - targetOffset.top;
+ }
+ current_selected_i = false;
+ // Reverse order - the most specific one should be deeper/"laster"
+ // Basically, find the most 'deepest'
+ var possible_elements = [];
+ xpath_data['size_pos'].forEach(function (item, index) {
+ // If we are in a bounding-box
+ if (e.offsetY > item.top * y_scale && e.offsetY < item.top * y_scale + item.height * y_scale
+ &&
+ e.offsetX > item.left * y_scale && e.offsetX < item.left * y_scale + item.width * y_scale
+
+ ) {
+ // Ignore really large ones, because we are scraping 'div' also from xpath_element_scraper but
+ // that div or whatever could be some wrapper and would generally make you select the whole page
+ if (item.width > 800 && item.height > 400) {
+ return
+ }
+
+ // There could be many elements here, record them all and then we'll find out which is the most 'useful'
+ // (input, textarea, button, A etc)
+ if (item.width < xpath_data['browser_width']) {
+ possible_elements.push(item);
+ }
+ }
+ });
+
+ // Find the best one
+ if (possible_elements.length) {
+ possible_elements.forEach(function (item, index) {
+ if (["a", "input", "textarea", "button"].includes(item['tagName'])) {
+ current_selected_i = item;
+ }
+ });
+
+ if (!current_selected_i) {
+ current_selected_i = possible_elements[0];
+ }
+
+ sel = xpath_data['size_pos'][current_selected_i];
+ ctx.strokeRect(current_selected_i.left * x_scale, current_selected_i.top * y_scale, current_selected_i.width * x_scale, current_selected_i.height * y_scale);
+ ctx.fillRect(current_selected_i.left * x_scale, current_selected_i.top * y_scale, current_selected_i.width * x_scale, current_selected_i.height * y_scale);
+ }
+
+
+ }.debounce(10));
+ });
+
+// $("#browser-steps-fieldlist").bind('mouseover', function(e) {
+// console.log(e.xpath_data_index);
+ // });
+
+
+ // callback for clicking on an xpath on the canvas
+ function process_selected(selected_in_xpath_list) {
+ found_something = false;
+ var first_available = $("ul#browser_steps li.empty").first();
+
+
+ if (selected_in_xpath_list !== false) {
+ // Nothing focused, so fill in a new one
+ // if inpt type button or
+ // from the top, find the next not used one and use it
+ var x = selected_in_xpath_list;
+ console.log(x);
+ if (x && first_available.length) {
+ // @todo will it let you click shit that has a layer ontop? probably not.
+ if (x['tagtype'] === 'text' || x['tagtype'] === 'number' || x['tagtype'] === 'email' || x['tagName'] === 'textarea' || x['tagtype'] === 'password' || x['tagtype'] === 'search') {
+ $('select', first_available).val('Enter text in field').change();
+ $('input[type=text]', first_available).first().val(x['xpath']);
+ $('input[placeholder="Value"]', first_available).addClass('ok').click().focus();
+ found_something = true;
+ }
+ else if (x['tagName'] === 'select') {
+ $('select', first_available).val(' by option text').change();
+ $('input[type=text]', first_available).first().val(x['xpath']);
+ $('input[placeholder="Value"]', first_available).addClass('ok').click().focus();
+ found_something = true;
+ }
+ else {
+ // There's no good way (that I know) to find if this
+ // see https://stackoverflow.com/questions/446892/how-to-find-event-listeners-on-a-dom-node-in-javascript-or-in-debugging
+ // https://codepen.io/azaslavsky/pen/DEJVWv
+
+ // So we dont know if its really a clickable element or not :-(
+ // Assume it is - then we dont fill the pages with unreliable "Click X,Y" selections
+ // If you switch to "Click X,y" after an element here is setup, it will give the last co-ords anyway
+ //if (x['isClickable'] || x['tagName'].startsWith('h') || x['tagName'] === 'a' || x['tagName'] === 'button' || x['tagtype'] === 'submit' || x['tagtype'] === 'checkbox' || x['tagtype'] === 'radio' || x['tagtype'] === 'li') {
+ $('select', first_available).val('Click element').change();
+ $('input[type=text]', first_available).first().val(x['xpath']).focus();
+ found_something = true;
+ //}
+ }
+ }
+ }
+ }
+
+ function draw_circle_on_canvas(x, y) {
+ ctx.beginPath();
+ ctx.arc(x, y, 8, 0, 2 * Math.PI, false);
+ ctx.fillStyle = 'rgba(255,0,0, 0.6)';
+ ctx.fill();
+ }
+
+ function start() {
+ console.log("Starting browser-steps UI");
+ browsersteps_session_id = false;
+ // @todo This setting of the first one should be done at the datalayer but wtforms doesnt wanna play nice
+ $('#browser_steps >li:first-child').removeClass('empty');
+ set_first_gotosite_disabled();
+ $('#browser-steps-ui .loader .spinner').show();
+ $('.clear,.remove', $('#browser_steps >li:first-child')).hide();
+ $.ajax({
+ type: "GET",
+ url: browser_steps_start_url,
+ statusCode: {
+ 400: function () {
+ // More than likely the CSRF token was lost when the server restarted
+ alert("There was a problem processing the request, please reload the page.");
+ },
+ 401: function (err) {
+ // This will be a custom error
+ alert(err.responseText);
+ }
+ }
+ }).done(function (data) {
+ $("#loading-status-text").fadeIn();
+ browsersteps_session_id = data.browsersteps_session_id;
+ // This should trigger 'Goto site'
+ console.log("Got startup response, requesting Goto-Site (first) step fake click");
+ $('#browser_steps >li:first-child .apply').click();
+ browser_interface_seconds_remaining = 500;
+ set_first_gotosite_disabled();
+ }).fail(function (data) {
+ console.log(data);
+ alert('There was an error communicating with the server.');
+ });
+
+ }
+
+ function disable_browsersteps_ui() {
+ set_first_gotosite_disabled();
+ $("#browser-steps-ui").css('opacity', '0.3');
+ $('#browsersteps-selector-canvas').off("mousemove mousedown click");
+ }
+
+
+ ////////////////////////// STEPS UI ////////////////////
+ $('ul#browser_steps [type="text"]').keydown(function (e) {
+ if (e.keyCode === 13) {
+ // hitting [enter] in a browser-step input should trigger the 'Apply'
+ e.preventDefault();
+ $(".apply", $(this).closest('li')).click();
+ return false;
+ }
+ });
+
+ // Look up which step was selected, and enable or disable the related extra fields
+ // So that people using it dont' get confused
+ $('ul#browser_steps select').on("change", function () {
+ var config = browser_steps_config[$(this).val()].split(' ');
+ var elem_selector = $('tr:nth-child(2) input', $(this).closest('tbody'));
+ var elem_value = $('tr:nth-child(3) input', $(this).closest('tbody'));
+
+ if (config[0] == 0) {
+ $(elem_selector).fadeOut();
+ } else {
+ $(elem_selector).fadeIn();
+ }
+ if (config[1] == 0) {
+ $(elem_value).fadeOut();
+ } else {
+ $(elem_value).fadeIn();
+ }
+
+ if ($(this).val() === 'Click X,Y' && last_click_xy['x'] > 0 && $(elem_value).val().length === 0) {
+ // @todo handle scale
+ $(elem_value).val(last_click_xy['x'] + ',' + last_click_xy['y']).focus();
+ }
+ }).change();
+
+ function set_greyed_state() {
+ $('ul#browser_steps select').not('option:selected[value="Choose one"]').closest('li').removeClass('empty');
+ $('ul#browser_steps select option:selected[value="Choose one"]').closest('li').addClass('empty');
+ }
+
+ // Add the extra buttons to the steps
+ $('ul#browser_steps li').each(function (i) {
+ var s = '' + '
Apply ';
+ if (i > 0) {
+ // The first step never gets these (Goto-site)
+ s += `
Clear ` +
+ `
Remove `;
+
+ // if a screenshot is available
+ if (browser_steps_available_screenshots.includes(i.toString())) {
+ var d = (browser_steps_last_error_step === i+1) ? 'before' : 'after';
+ s += `
Pic `;
+ }
+ }
+ s += '
';
+ $(this).append(s)
+ }
+ );
+
+ $('ul#browser_steps li .control .clear').click(function (element) {
+ $("select", $(this).closest('li')).val("Choose one").change();
+ $(":text", $(this).closest('li')).val('');
+ });
+
+
+ $('ul#browser_steps li .control .remove').click(function (element) {
+ // so you wanna remove the 2nd (3rd spot 0,1,2,...)
+ var p = $("#browser_steps li").index($(this).closest('li'));
+
+ var elem_to_remove = $("#browser_steps li")[p];
+ $('.clear', elem_to_remove).click();
+ $("#browser_steps li").slice(p, 10).each(function (index) {
+ // get the next one's value from where we clicked
+ var next = $("#browser_steps li")[p + index + 1];
+ if (next) {
+ // and set THIS ones value from the next one
+ var n = $('input', next);
+ $("select", $(this)).val($('select', next).val());
+ $('input', this)[0].value = $(n)[0].value;
+ $('input', this)[1].value = $(n)[1].value;
+ // Triggers reconfiguring the field based on the system config
+ $("select", $(this)).change();
+ }
+
+ });
+
+ // Reset their hidden/empty states
+ set_greyed_state();
+ });
+
+ $('ul#browser_steps li .control .apply').click(function (event) {
+ // sequential requests @todo refactor
+ if (apply_buttons_disabled) {
+ return;
+ }
+
+ var current_data = $(event.currentTarget).closest('li');
+ $('#browser-steps-ui .loader .spinner').fadeIn();
+ apply_buttons_disabled = true;
+ $('ul#browser_steps li .control .apply').css('opacity', 0.5);
+ $("#browsersteps-img").css('opacity', 0.65);
+
+ var is_last_step = 0;
+ var step_n = $(event.currentTarget).data('step-index');
+
+ // On the last step, we should also be getting data ready for the visual selector
+ $('ul#browser_steps li select').each(function (i) {
+ if ($(this).val() !== 'Choose one') {
+ is_last_step += 1;
+ }
+ });
+
+ if (is_last_step == (step_n + 1)) {
+ is_last_step = true;
+ } else {
+ is_last_step = false;
+ }
+
+ console.log("Requesting step via POST " + $("select[id$='operation']", current_data).first().val());
+ // POST the currently clicked step form widget back and await response, redraw
+ $.ajax({
+ method: "POST",
+ url: browser_steps_sync_url + "&browsersteps_session_id=" + browsersteps_session_id,
+ data: {
+ 'operation': $("select[id$='operation']", current_data).first().val(),
+ 'selector': $("input[id$='selector']", current_data).first().val(),
+ 'optional_value': $("input[id$='optional_value']", current_data).first().val(),
+ 'step_n': step_n,
+ 'is_last_step': is_last_step
+ },
+ statusCode: {
+ 400: function () {
+ // More than likely the CSRF token was lost when the server restarted
+ alert("There was a problem processing the request, please reload the page.");
+ $("#loading-status-text").hide();
+ $('#browser-steps-ui .loader .spinner').fadeOut();
+ },
+ 401: function (data) {
+ // More than likely the CSRF token was lost when the server restarted
+ alert(data.responseText);
+ $("#loading-status-text").hide();
+ $('#browser-steps-ui .loader .spinner').fadeOut();
+ }
+ }
+ }).done(function (data) {
+ // it should return the new state (selectors available and screenshot)
+ xpath_data = data.xpath_data;
+ $('#browsersteps-img').attr('src', data.screenshot);
+ $('#browser-steps-ui .loader .spinner').fadeOut();
+ apply_buttons_disabled = false;
+ $("#browsersteps-img").css('opacity', 1);
+ $('ul#browser_steps li .control .apply').css('opacity', 1);
+ $("#loading-status-text").hide();
+ set_first_gotosite_disabled();
+ }).fail(function (data) {
+ console.log(data);
+ if (data.responseText.includes("Browser session expired")) {
+ disable_browsersteps_ui();
+ }
+ apply_buttons_disabled = false;
+ $("#loading-status-text").hide();
+ $('ul#browser_steps li .control .apply').css('opacity', 1);
+ $("#browsersteps-img").css('opacity', 1);
+ });
+
+ });
+
+ $('ul#browser_steps li .control .show-screenshot').click(function (element) {
+ var step_n = $(event.currentTarget).data('step-index');
+ w = window.open(this.href, "_blank", "width=640,height=480");
+ const t = $(event.currentTarget).data('type');
+
+ const url = browser_steps_fetch_screenshot_image_url + `&step_n=${step_n}&type=${t}`;
+ w.document.body.innerHTML = `
+
+
+
+
+ `;
+ w.document.title = `Browser Step at step ${step_n} from last run.`;
+ });
+
+ if (browser_steps_last_error_step) {
+ $("ul#browser_steps>li:nth-child("+browser_steps_last_error_step+")").addClass("browser-step-with-error");
+ }
+
+ $("ul#browser_steps select").change(function () {
+ set_greyed_state();
+ }).change();
+
+});
\ No newline at end of file
diff --git a/changedetectionio/static/js/conditions.js b/changedetectionio/static/js/conditions.js
new file mode 100644
index 0000000000000000000000000000000000000000..6bbfbc6635aa7a8a4d53141c9f0fc56844e4946b
--- /dev/null
+++ b/changedetectionio/static/js/conditions.js
@@ -0,0 +1,154 @@
+$(document).ready(function () {
+ // Function to set up button event handlers
+ function setupButtonHandlers() {
+ // Unbind existing handlers first to prevent duplicates
+ $(".addRuleRow, .removeRuleRow, .verifyRuleRow").off("click");
+
+ // Add row button handler
+ $(".addRuleRow").on("click", function(e) {
+ e.preventDefault();
+
+ let currentRow = $(this).closest(".fieldlist-row");
+
+ // Clone without events
+ let newRow = currentRow.clone(false);
+
+ // Reset input values in the cloned row
+ newRow.find("input").val("");
+ newRow.find("select").prop("selectedIndex", 0);
+
+ // Insert the new row after the current one
+ currentRow.after(newRow);
+
+ // Reindex all rows
+ reindexRules();
+ });
+
+ // Remove row button handler
+ $(".removeRuleRow").on("click", function(e) {
+ e.preventDefault();
+
+ // Only remove if there's more than one row
+ if ($("#rulesTable .fieldlist-row").length > 1) {
+ $(this).closest(".fieldlist-row").remove();
+ reindexRules();
+ }
+ });
+
+ // Verify rule button handler
+ $(".verifyRuleRow").on("click", function(e) {
+ e.preventDefault();
+
+ let row = $(this).closest(".fieldlist-row");
+ let field = row.find("select[name$='field']").val();
+ let operator = row.find("select[name$='operator']").val();
+ let value = row.find("input[name$='value']").val();
+
+ // Validate that all fields are filled
+ if (!field || field === "None" || !operator || operator === "None" || !value) {
+ alert("Please fill in all fields (Field, Operator, and Value) before verifying.");
+ return;
+ }
+
+
+ // Create a rule object
+ let rule = {
+ field: field,
+ operator: operator,
+ value: value
+ };
+
+ // Show a spinner or some indication that verification is in progress
+ const $button = $(this);
+ const originalHTML = $button.html();
+ $button.html("⌛").prop("disabled", true);
+
+ // Collect form data - similar to request_textpreview_update() in watch-settings.js
+ let formData = new FormData();
+ $('#edit-text-filter textarea, #edit-text-filter input').each(function() {
+ const $element = $(this);
+ const name = $element.attr('name');
+ if (name) {
+ if ($element.is(':checkbox')) {
+ formData.append(name, $element.is(':checked') ? $element.val() : false);
+ } else {
+ formData.append(name, $element.val());
+ }
+ }
+ });
+
+ // Also collect select values
+ $('#edit-text-filter select').each(function() {
+ const $element = $(this);
+ const name = $element.attr('name');
+ if (name) {
+ formData.append(name, $element.val());
+ }
+ });
+
+
+ // Send the request to verify the rule
+ $.ajax({
+ url: verify_condition_rule_url+"?"+ new URLSearchParams({ rule: JSON.stringify(rule) }).toString(),
+ type: "POST",
+ data: formData,
+ processData: false, // Prevent jQuery from converting FormData to a string
+ contentType: false, // Let the browser set the correct content type
+ success: function (response) {
+ if (response.status === "success") {
+ if(rule['field'] !== "page_filtered_text") {
+ // A little debug helper for the user
+ $('#verify-state-text').text(`${rule['field']} was value "${response.data[rule['field']]}"`)
+ }
+ if (response.result) {
+ alert("✅ Condition PASSES verification against current snapshot!");
+ } else {
+ alert("❌ Condition FAILS verification against current snapshot.");
+ }
+ } else {
+ alert("Error: " + response.message);
+ }
+ $button.html(originalHTML).prop("disabled", false);
+ },
+ error: function (xhr) {
+ let errorMsg = "Error verifying condition.";
+ if (xhr.responseJSON && xhr.responseJSON.message) {
+ errorMsg = xhr.responseJSON.message;
+ }
+ alert(errorMsg);
+ $button.html(originalHTML).prop("disabled", false);
+ }
+ });
+ });
+ }
+
+ // Function to reindex form elements and re-setup event handlers
+ function reindexRules() {
+ // Unbind all button handlers first
+ $(".addRuleRow, .removeRuleRow, .verifyRuleRow").off("click");
+
+ // Reindex all form elements
+ $("#rulesTable .fieldlist-row").each(function(index) {
+ $(this).find("select, input").each(function() {
+ let oldName = $(this).attr("name");
+ let oldId = $(this).attr("id");
+
+ if (oldName) {
+ let newName = oldName.replace(/\d+/, index);
+ $(this).attr("name", newName);
+ }
+
+ if (oldId) {
+ let newId = oldId.replace(/\d+/, index);
+ $(this).attr("id", newId);
+ }
+ });
+ });
+
+ // Reattach event handlers after reindexing
+ setupButtonHandlers();
+ }
+
+ // Initial setup of button handlers
+ setupButtonHandlers();
+});
diff --git a/changedetectionio/static/js/csrf.js b/changedetectionio/static/js/csrf.js
new file mode 100644
index 0000000000000000000000000000000000000000..4e2aca534450e6328feba06813b3546a5443f00e
--- /dev/null
+++ b/changedetectionio/static/js/csrf.js
@@ -0,0 +1,10 @@
+$(document).ready(function () {
+ $.ajaxSetup({
+ beforeSend: function (xhr, settings) {
+ if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
+ xhr.setRequestHeader("X-CSRFToken", csrftoken)
+ }
+ }
+ })
+});
+
diff --git a/changedetectionio/static/js/diff-overview.js b/changedetectionio/static/js/diff-overview.js
new file mode 100644
index 0000000000000000000000000000000000000000..1f501529dcf789614e6248b9cef19a84ff084fe6
--- /dev/null
+++ b/changedetectionio/static/js/diff-overview.js
@@ -0,0 +1,103 @@
+$(document).ready(function () {
+ $('.needs-localtime').each(function () {
+ for (var option of this.options) {
+ var dateObject = new Date(option.value * 1000);
+ option.label = dateObject.toLocaleString(undefined, {dateStyle: "full", timeStyle: "medium"});
+ }
+ });
+
+ // Load it when the #screenshot tab is in use, so we dont give a slow experience when waiting for the text diff to load
+ window.addEventListener('hashchange', function (e) {
+ toggle(location.hash);
+ }, false);
+
+ toggle(location.hash);
+
+ function toggle(hash_name) {
+ if (hash_name === '#screenshot') {
+ $("img#screenshot-img").attr('src', screenshot_url);
+ $("#settings").hide();
+ } else if (hash_name === '#error-screenshot') {
+ $("img#error-screenshot-img").attr('src', error_screenshot_url);
+ $("#settings").hide();
+ } else if (hash_name === '#extract') {
+ $("#settings").hide();
+ } else {
+ $("#settings").show();
+ }
+ }
+
+ const article = $('.highlightable-filter')[0];
+
+ // We could also add the 'touchend' event for touch devices, but since
+ // most iOS/Android browsers already show a dialog when you select
+ // text (often with a Share option) we'll skip that
+ article.addEventListener('mouseup', dragTextHandler, false);
+ article.addEventListener('mousedown', clean, false);
+
+ function clean(event) {
+ $("#highlightSnippet").remove();
+ }
+
+ // Listen for Escape key press
+ window.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') {
+ clean();
+ }
+ }, false);
+
+ function dragTextHandler(event) {
+ console.log('mouseupped');
+
+ // Check if any text was selected
+ if (window.getSelection().toString().length > 0) {
+
+ // Find out how much (if any) user has scrolled
+ var scrollTop = (window.pageYOffset !== undefined) ? window.pageYOffset : (document.documentElement || document.body.parentNode || document.body).scrollTop;
+
+ // Get cursor position
+ const posX = event.clientX;
+ const posY = event.clientY + 20 + scrollTop;
+
+ // Append HTML to the body, create the "Tweet Selection" dialog
+ document.body.insertAdjacentHTML('beforeend', '');
+
+ if (/\d/.test(window.getSelection().toString())) {
+ // Offer regex replacement
+ document.getElementById("highlightSnippet").insertAdjacentHTML('beforeend', 'Ignore text including number changes ');
+ }
+
+ $('#highlightSnippet a').bind('click', function (e) {
+ if(!window.getSelection().toString().trim().length) {
+ alert('Oops no text selected!');
+ return;
+ }
+
+ $.ajax({
+ type: "POST",
+ url: highlight_submit_ignore_url,
+ data: {'mode': $(this).data('mode'), 'selection': window.getSelection().toString()},
+ statusCode: {
+ 400: function () {
+ // More than likely the CSRF token was lost when the server restarted
+ alert("There was a problem processing the request, please reload the page.");
+ }
+ }
+ }).done(function (data) {
+ $("#highlightSnippet").html(data)
+ }).fail(function (data) {
+ console.log(data);
+ alert('There was an error communicating with the server.');
+ });
+ });
+
+ }
+ }
+
+ $('#diff-form').on('submit', function (e) {
+ if ($('select[name=from_version]').val() === $('select[name=to_version]').val()) {
+ e.preventDefault();
+ alert('Error - You are trying to compare the same version.');
+ }
+ });
+});
diff --git a/changedetectionio/static/js/diff-render.js b/changedetectionio/static/js/diff-render.js
new file mode 100644
index 0000000000000000000000000000000000000000..ea69d364f00d2fdd1569afedbe3345e733175bb9
--- /dev/null
+++ b/changedetectionio/static/js/diff-render.js
@@ -0,0 +1,115 @@
+$(document).ready(function () {
+ var a = document.getElementById("a");
+ var b = document.getElementById("b");
+ var result = document.getElementById("result");
+ var inputs;
+
+ $('#jump-next-diff').click(function () {
+
+ var element = inputs[inputs.current];
+ var headerOffset = 80;
+ var elementPosition = element.getBoundingClientRect().top;
+ var offsetPosition = elementPosition - headerOffset + window.scrollY;
+
+ window.scrollTo({
+ top: offsetPosition,
+ behavior: "smooth",
+ });
+
+ inputs.current++;
+ if (inputs.current >= inputs.length) {
+ inputs.current = 0;
+ }
+ });
+
+ function changed() {
+ // https://github.com/kpdecker/jsdiff/issues/389
+ // I would love to use `{ignoreWhitespace: true}` here but it breaks the formatting
+ options = {
+ ignoreWhitespace: document.getElementById("ignoreWhitespace").checked,
+ };
+
+ var diff = Diff[window.diffType](a.textContent, b.textContent, options);
+ var fragment = document.createDocumentFragment();
+ for (var i = 0; i < diff.length; i++) {
+ if (diff[i].added && diff[i + 1] && diff[i + 1].removed) {
+ var swap = diff[i];
+ diff[i] = diff[i + 1];
+ diff[i + 1] = swap;
+ }
+
+ var node;
+ if (diff[i].removed) {
+ node = document.createElement("del");
+ node.classList.add("change");
+ const wrapper = node.appendChild(document.createElement("span"));
+ wrapper.appendChild(document.createTextNode(diff[i].value));
+ } else if (diff[i].added) {
+ node = document.createElement("ins");
+ node.classList.add("change");
+ const wrapper = node.appendChild(document.createElement("span"));
+ wrapper.appendChild(document.createTextNode(diff[i].value));
+ } else {
+ node = document.createTextNode(diff[i].value);
+ }
+ fragment.appendChild(node);
+ }
+
+ result.textContent = "";
+ result.appendChild(fragment);
+
+ // For nice mouse-over hover/title information
+ const removed_current_option = $('#diff-version option:selected')
+ if (removed_current_option) {
+ $('del').each(function () {
+ $(this).prop('title', 'Removed '+removed_current_option[0].label);
+ });
+ }
+ const inserted_current_option = $('#current-version option:selected')
+ if (removed_current_option) {
+ $('ins').each(function () {
+ $(this).prop('title', 'Inserted '+inserted_current_option[0].label);
+ });
+ }
+ // Set the list of possible differences to jump to
+ inputs = document.querySelectorAll('#diff-ui .change')
+ // Set the "current" diff pointer
+ inputs.current = 0;
+ // Goto diff
+ $('#jump-next-diff').click();
+ }
+
+
+ onDiffTypeChange(
+ document.querySelector('#settings [name="diff_type"]:checked'),
+ );
+ changed();
+
+ a.onpaste = a.onchange = b.onpaste = b.onchange = changed;
+
+ if ("oninput" in a) {
+ a.oninput = b.oninput = changed;
+ } else {
+ a.onkeyup = b.onkeyup = changed;
+ }
+
+ function onDiffTypeChange(radio) {
+ window.diffType = radio.value;
+ // Not necessary
+ // document.title = "Diff " + radio.value.slice(4);
+ }
+
+ var radio = document.getElementsByName("diff_type");
+ for (var i = 0; i < radio.length; i++) {
+ radio[i].onchange = function (e) {
+ onDiffTypeChange(e.target);
+ changed();
+ };
+ }
+
+ document.getElementById("ignoreWhitespace").onchange = function (e) {
+ changed();
+ };
+
+});
+
diff --git a/changedetectionio/static/js/diff.min.js b/changedetectionio/static/js/diff.min.js
new file mode 100644
index 0000000000000000000000000000000000000000..80c20de5b757df65b80f1456360f29b659f86491
--- /dev/null
+++ b/changedetectionio/static/js/diff.min.js
@@ -0,0 +1,38 @@
+/*!
+
+ diff v5.1.0
+
+Software License Agreement (BSD License)
+
+Copyright (c) 2009-2015, Kevin Decker
+
+All rights reserved.
+
+Redistribution and use of this software in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above
+ copyright notice, this list of conditions and the
+ following disclaimer.
+
+* Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the
+ following disclaimer in the documentation and/or other
+ materials provided with the distribution.
+
+* Neither the name of Kevin Decker nor the names of its
+ contributors may be used to endorse or promote products
+ derived from this software without specific prior
+ written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
+IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
+OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+@license
+*/
+!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((e=e||self).Diff={})}(this,function(e){"use strict";function t(){}t.prototype={diff:function(u,a,e){var n=2=c&&h<=i+1)return d([{value:this.join(a),count:a.length}]);function o(){for(var e,n=-1*p;n<=p;n+=2){var t=void 0,r=v[n-1],i=v[n+1],o=(i?i.newPos:0)-n;r&&(v[n-1]=void 0);var l=r&&r.newPos+1=c&&h<=o+1)return d(function(e,n,t,r,i){for(var o=0,l=n.length,s=0,u=0;oe.length?t:e}),d.value=e.join(f)):d.value=e.join(t.slice(s,s+d.count)),s+=d.count,d.added||(u+=d.count))}var c=n[l-1];1e.length)&&(n=e.length);for(var t=0,r=new Array(n);t=c.length-2&&u.length<=d.context&&(i=/\n$/.test(a),o=/\n$/.test(f),l=0==u.length&&g.length>r.oldLines,!i&&l&&0e.length)return!1;for(var t=0;t"):i.removed&&t.push(""),t.push((n=i.value,n.replace(/&/g,"&").replace(//g,">").replace(/"/g,"""))),i.added?t.push(""):i.removed&&t.push("")}return t.join("")},e.createPatch=function(e,n,t,r,i,o){return y(e,e,n,t,r,i,o)},e.createTwoFilesPatch=y,e.diffArrays=function(e,n,t){return g.diff(e,n,t)},e.diffChars=function(e,n,t){return r.diff(e,n,t)},e.diffCss=function(e,n,t){return f.diff(e,n,t)},e.diffJson=function(e,n,t){return p.diff(e,n,t)},e.diffLines=L,e.diffSentences=function(e,n,t){return a.diff(e,n,t)},e.diffTrimmedLines=function(e,n,t){var r=i(t,{ignoreWhitespace:!0});return u.diff(e,n,r)},e.diffWords=function(e,n,t){return t=i(t,{ignoreWhitespace:!0}),s.diff(e,n,t)},e.diffWordsWithSpace=function(e,n,t){return s.diff(e,n,t)},e.merge=function(e,n,t){e=b(e,t),n=b(n,t);var r={};(e.index||n.index)&&(r.index=e.index||n.index),(e.newFileName||n.newFileName)&&(F(e)?F(n)?(r.oldFileName=N(r,e.oldFileName,n.oldFileName),r.newFileName=N(r,e.newFileName,n.newFileName),r.oldHeader=N(r,e.oldHeader,n.oldHeader),r.newHeader=N(r,e.newHeader,n.newHeader)):(r.oldFileName=e.oldFileName,r.newFileName=e.newFileName,r.oldHeader=e.oldHeader,r.newHeader=e.newHeader):(r.oldFileName=n.oldFileName||e.oldFileName,r.newFileName=n.newFileName||e.newFileName,r.oldHeader=n.oldHeader||e.oldHeader,r.newHeader=n.newHeader||e.newHeader)),r.hunks=[];for(var i=0,o=0,l=0,s=0;i0?t:i)(e)}},function(e,n,i){var t;
+/*!
+ Copyright (c) 2016 Jed Watson.
+ Licensed under the MIT License (MIT), see
+ http://jedwatson.github.io/classnames
+*/
+/*!
+ Copyright (c) 2016 Jed Watson.
+ Licensed under the MIT License (MIT), see
+ http://jedwatson.github.io/classnames
+*/
+!function(){"use strict";var i=function(){function e(){}function n(e,n){for(var i=n.length,t=0;t0?l(t(e),9007199254740991):0}},function(e,n,i){var t=i(1),l=i(14),r=i(68),o=i(15),a=r(!1);e.exports=function(e,n){var i,r=l(e),c=0,p=[];for(i in r)!t(o,i)&&t(r,i)&&p.push(i);for(;n.length>c;)t(r,i=n[c++])&&(~a(p,i)||p.push(i));return p}},function(e,n,i){var t=i(0),l=i(11),r=i(5),o=i(1),a=i(19),c=i(36),p=i(37),y=p.get,h=p.enforce,x=String(c).split("toString");l("inspectSource",function(e){return c.call(e)}),(e.exports=function(e,n,i,l){var c=!!l&&!!l.unsafe,p=!!l&&!!l.enumerable,y=!!l&&!!l.noTargetGet;"function"==typeof i&&("string"!=typeof n||o(i,"name")||r(i,"name",n),h(i).source=x.join("string"==typeof n?n:"")),e!==t?(c?!y&&e[n]&&(p=!0):delete e[n],p?e[n]=i:r(e,n,i)):p?e[n]=i:a(n,i)})(Function.prototype,"toString",function(){return"function"==typeof this&&y(this).source||c.call(this)})},function(e,n){var i={}.toString;e.exports=function(e){return i.call(e).slice(8,-1)}},function(e,n,i){var t=i(8),l=i(73),r=i(10),o=i(14),a=i(18),c=i(1),p=i(35),y=Object.getOwnPropertyDescriptor;n.f=t?y:function(e,n){if(e=o(e),n=a(n,!0),p)try{return y(e,n)}catch(e){}if(c(e,n))return r(!l.f.call(e,n),e[n])}},function(e,n,i){var t=i(0),l=i(31).f,r=i(5),o=i(29),a=i(19),c=i(71),p=i(65);e.exports=function(e,n){var i,y,h,x,s,u=e.target,d=e.global,f=e.stat;if(i=d?t:f?t[u]||a(u,{}):(t[u]||{}).prototype)for(y in n){if(x=n[y],h=e.noTargetGet?(s=l(i,y))&&s.value:i[y],!p(d?y:u+(f?".":"#")+y,e.forced)&&void 0!==h){if(typeof x==typeof h)continue;c(x,h)}(e.sham||h&&h.sham)&&r(x,"sham",!0),o(i,y,x,e)}}},function(e,n){var i=0,t=Math.random();e.exports=function(e){return"Symbol(".concat(void 0===e?"":e,")_",(++i+t).toString(36))}},function(e,n,i){var t=i(0),l=i(6),r=t.document,o=l(r)&&l(r.createElement);e.exports=function(e){return o?r.createElement(e):{}}},function(e,n,i){var t=i(8),l=i(4),r=i(34);e.exports=!t&&!l(function(){return 7!=Object.defineProperty(r("div"),"a",{get:function(){return 7}}).a})},function(e,n,i){var t=i(11);e.exports=t("native-function-to-string",Function.toString)},function(e,n,i){var t,l,r,o=i(76),a=i(0),c=i(6),p=i(5),y=i(1),h=i(16),x=i(15),s=a.WeakMap;if(o){var u=new s,d=u.get,f=u.has,g=u.set;t=function(e,n){return g.call(u,e,n),n},l=function(e){return d.call(u,e)||{}},r=function(e){return f.call(u,e)}}else{var v=h("state");x[v]=!0,t=function(e,n){return p(e,v,n),n},l=function(e){return y(e,v)?e[v]:{}},r=function(e){return y(e,v)}}e.exports={set:t,get:l,has:r,enforce:function(e){return r(e)?l(e):t(e,{})},getterFor:function(e){return function(n){var i;if(!c(n)||(i=l(n)).type!==e)throw TypeError("Incompatible receiver, "+e+" required");return i}}}},function(e,n,i){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var t=Object.assign||function(e){for(var n=1;n0&&void 0!==arguments[0]?arguments[0]:{};if("undefined"==typeof document)throw new Error("`feather.replace()` only works in a browser environment.");var n=document.querySelectorAll("[data-feather]");Array.from(n).forEach(function(n){return function(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=function(e){return Array.from(e.attributes).reduce(function(e,n){return e[n.name]=n.value,e},{})}(e),o=i["data-feather"];if(delete i["data-feather"],void 0!==r.default[o]){var a=r.default[o].toSvg(t({},n,i,{class:(0,l.default)(n.class,i.class)})),c=(new DOMParser).parseFromString(a,"image/svg+xml").querySelector("svg");e.parentNode.replaceChild(c,e)}else console.warn("feather: '"+o+"' is not a valid icon")}(n,e)})}},function(e,n,i){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var t,l=i(12),r=(t=l)&&t.__esModule?t:{default:t};n.default=function(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(console.warn("feather.toSvg() is deprecated. Please use feather.icons[name].toSvg() instead."),!e)throw new Error("The required `key` (icon name) parameter is missing.");if(!r.default[e])throw new Error("No icon matching '"+e+"'. See the complete list of icons at https://feathericons.com");return r.default[e].toSvg(n)}},function(e){e.exports={activity:["pulse","health","action","motion"],airplay:["stream","cast","mirroring"],"alert-circle":["warning","alert","danger"],"alert-octagon":["warning","alert","danger"],"alert-triangle":["warning","alert","danger"],"align-center":["text alignment","center"],"align-justify":["text alignment","justified"],"align-left":["text alignment","left"],"align-right":["text alignment","right"],anchor:[],archive:["index","box"],"at-sign":["mention","at","email","message"],award:["achievement","badge"],aperture:["camera","photo"],"bar-chart":["statistics","diagram","graph"],"bar-chart-2":["statistics","diagram","graph"],battery:["power","electricity"],"battery-charging":["power","electricity"],bell:["alarm","notification","sound"],"bell-off":["alarm","notification","silent"],bluetooth:["wireless"],"book-open":["read","library"],book:["read","dictionary","booklet","magazine","library"],bookmark:["read","clip","marker","tag"],box:["cube"],briefcase:["work","bag","baggage","folder"],calendar:["date"],camera:["photo"],cast:["chromecast","airplay"],"chevron-down":["expand"],"chevron-up":["collapse"],circle:["off","zero","record"],clipboard:["copy"],clock:["time","watch","alarm"],"cloud-drizzle":["weather","shower"],"cloud-lightning":["weather","bolt"],"cloud-rain":["weather"],"cloud-snow":["weather","blizzard"],cloud:["weather"],codepen:["logo"],codesandbox:["logo"],code:["source","programming"],coffee:["drink","cup","mug","tea","cafe","hot","beverage"],columns:["layout"],command:["keyboard","cmd","terminal","prompt"],compass:["navigation","safari","travel","direction"],copy:["clone","duplicate"],"corner-down-left":["arrow","return"],"corner-down-right":["arrow"],"corner-left-down":["arrow"],"corner-left-up":["arrow"],"corner-right-down":["arrow"],"corner-right-up":["arrow"],"corner-up-left":["arrow"],"corner-up-right":["arrow"],cpu:["processor","technology"],"credit-card":["purchase","payment","cc"],crop:["photo","image"],crosshair:["aim","target"],database:["storage","memory"],delete:["remove"],disc:["album","cd","dvd","music"],"dollar-sign":["currency","money","payment"],droplet:["water"],edit:["pencil","change"],"edit-2":["pencil","change"],"edit-3":["pencil","change"],eye:["view","watch"],"eye-off":["view","watch","hide","hidden"],"external-link":["outbound"],facebook:["logo","social"],"fast-forward":["music"],figma:["logo","design","tool"],"file-minus":["delete","remove","erase"],"file-plus":["add","create","new"],"file-text":["data","txt","pdf"],film:["movie","video"],filter:["funnel","hopper"],flag:["report"],"folder-minus":["directory"],"folder-plus":["directory"],folder:["directory"],framer:["logo","design","tool"],frown:["emoji","face","bad","sad","emotion"],gift:["present","box","birthday","party"],"git-branch":["code","version control"],"git-commit":["code","version control"],"git-merge":["code","version control"],"git-pull-request":["code","version control"],github:["logo","version control"],gitlab:["logo","version control"],globe:["world","browser","language","translate"],"hard-drive":["computer","server","memory","data"],hash:["hashtag","number","pound"],headphones:["music","audio","sound"],heart:["like","love","emotion"],"help-circle":["question mark"],hexagon:["shape","node.js","logo"],home:["house","living"],image:["picture"],inbox:["email"],instagram:["logo","camera"],key:["password","login","authentication","secure"],layers:["stack"],layout:["window","webpage"],"life-buoy":["help","life ring","support"],link:["chain","url"],"link-2":["chain","url"],linkedin:["logo","social media"],list:["options"],lock:["security","password","secure"],"log-in":["sign in","arrow","enter"],"log-out":["sign out","arrow","exit"],mail:["email","message"],"map-pin":["location","navigation","travel","marker"],map:["location","navigation","travel"],maximize:["fullscreen"],"maximize-2":["fullscreen","arrows","expand"],meh:["emoji","face","neutral","emotion"],menu:["bars","navigation","hamburger"],"message-circle":["comment","chat"],"message-square":["comment","chat"],"mic-off":["record","sound","mute"],mic:["record","sound","listen"],minimize:["exit fullscreen","close"],"minimize-2":["exit fullscreen","arrows","close"],minus:["subtract"],monitor:["tv","screen","display"],moon:["dark","night"],"more-horizontal":["ellipsis"],"more-vertical":["ellipsis"],"mouse-pointer":["arrow","cursor"],move:["arrows"],music:["note"],navigation:["location","travel"],"navigation-2":["location","travel"],octagon:["stop"],package:["box","container"],paperclip:["attachment"],pause:["music","stop"],"pause-circle":["music","audio","stop"],"pen-tool":["vector","drawing"],percent:["discount"],"phone-call":["ring"],"phone-forwarded":["call"],"phone-incoming":["call"],"phone-missed":["call"],"phone-off":["call","mute"],"phone-outgoing":["call"],phone:["call"],play:["music","start"],"pie-chart":["statistics","diagram"],"play-circle":["music","start"],plus:["add","new"],"plus-circle":["add","new"],"plus-square":["add","new"],pocket:["logo","save"],power:["on","off"],printer:["fax","office","device"],radio:["signal"],"refresh-cw":["synchronise","arrows"],"refresh-ccw":["arrows"],repeat:["loop","arrows"],rewind:["music"],"rotate-ccw":["arrow"],"rotate-cw":["arrow"],rss:["feed","subscribe"],save:["floppy disk"],scissors:["cut"],search:["find","magnifier","magnifying glass"],send:["message","mail","email","paper airplane","paper aeroplane"],settings:["cog","edit","gear","preferences"],"share-2":["network","connections"],shield:["security","secure"],"shield-off":["security","insecure"],"shopping-bag":["ecommerce","cart","purchase","store"],"shopping-cart":["ecommerce","cart","purchase","store"],shuffle:["music"],"skip-back":["music"],"skip-forward":["music"],slack:["logo"],slash:["ban","no"],sliders:["settings","controls"],smartphone:["cellphone","device"],smile:["emoji","face","happy","good","emotion"],speaker:["audio","music"],star:["bookmark","favorite","like"],"stop-circle":["media","music"],sun:["brightness","weather","light"],sunrise:["weather","time","morning","day"],sunset:["weather","time","evening","night"],tablet:["device"],tag:["label"],target:["logo","bullseye"],terminal:["code","command line","prompt"],thermometer:["temperature","celsius","fahrenheit","weather"],"thumbs-down":["dislike","bad","emotion"],"thumbs-up":["like","good","emotion"],"toggle-left":["on","off","switch"],"toggle-right":["on","off","switch"],tool:["settings","spanner"],trash:["garbage","delete","remove","bin"],"trash-2":["garbage","delete","remove","bin"],triangle:["delta"],truck:["delivery","van","shipping","transport","lorry"],tv:["television","stream"],twitch:["logo"],twitter:["logo","social"],type:["text"],umbrella:["rain","weather"],unlock:["security"],"user-check":["followed","subscribed"],"user-minus":["delete","remove","unfollow","unsubscribe"],"user-plus":["new","add","create","follow","subscribe"],"user-x":["delete","remove","unfollow","unsubscribe","unavailable"],user:["person","account"],users:["group"],"video-off":["camera","movie","film"],video:["camera","movie","film"],voicemail:["phone"],volume:["music","sound","mute"],"volume-1":["music","sound"],"volume-2":["music","sound"],"volume-x":["music","sound","mute"],watch:["clock","time"],"wifi-off":["disabled"],wifi:["connection","signal","wireless"],wind:["weather","air"],"x-circle":["cancel","close","delete","remove","times","clear"],"x-octagon":["delete","stop","alert","warning","times","clear"],"x-square":["cancel","close","delete","remove","times","clear"],x:["cancel","close","delete","remove","times","clear"],youtube:["logo","video","play"],"zap-off":["flash","camera","lightning"],zap:["flash","camera","lightning"],"zoom-in":["magnifying glass"],"zoom-out":["magnifying glass"]}},function(e){e.exports={activity:' ',airplay:' ',"alert-circle":' ',"alert-octagon":' ',"alert-triangle":' ',"align-center":' ',"align-justify":' ',"align-left":' ',"align-right":' ',anchor:' ',aperture:' ',archive:' ',"arrow-down-circle":' ',"arrow-down-left":' ',"arrow-down-right":' ',"arrow-down":' ',"arrow-left-circle":' ',"arrow-left":' ',"arrow-right-circle":' ',"arrow-right":' ',"arrow-up-circle":' ',"arrow-up-left":' ',"arrow-up-right":' ',"arrow-up":' ',"at-sign":' ',award:' ',"bar-chart-2":' ',"bar-chart":' ',"battery-charging":' ',battery:' ',"bell-off":' ',bell:' ',bluetooth:' ',bold:' ',"book-open":' ',book:' ',bookmark:' ',box:' ',briefcase:' ',calendar:' ',"camera-off":' ',camera:' ',cast:' ',"check-circle":' ',"check-square":' ',check:' ',"chevron-down":' ',"chevron-left":' ',"chevron-right":' ',"chevron-up":' ',"chevrons-down":' ',"chevrons-left":' ',"chevrons-right":' ',"chevrons-up":' ',chrome:' ',circle:' ',clipboard:' ',clock:' ',"cloud-drizzle":' ',"cloud-lightning":' ',"cloud-off":' ',"cloud-rain":' ',"cloud-snow":' ',cloud:' ',code:' ',codepen:' ',codesandbox:' ',coffee:' ',columns:' ',command:' ',compass:' ',copy:' ',"corner-down-left":' ',"corner-down-right":' ',"corner-left-down":' ',"corner-left-up":' ',"corner-right-down":' ',"corner-right-up":' ',"corner-up-left":' ',"corner-up-right":' ',cpu:' ',"credit-card":' ',crop:' ',crosshair:' ',database:' ',delete:' ',disc:' ',"divide-circle":' ',"divide-square":' ',divide:' ',"dollar-sign":' ',"download-cloud":' ',download:' ',dribbble:' ',droplet:' ',"edit-2":' ',"edit-3":' ',edit:' ',"external-link":' ',"eye-off":' ',eye:' ',facebook:' ',"fast-forward":' ',feather:' ',figma:' ',"file-minus":' ',"file-plus":' ',"file-text":' ',file:' ',film:' ',filter:' ',flag:' ',"folder-minus":' ',"folder-plus":' ',folder:' ',framer:' ',frown:' ',gift:' ',"git-branch":' ',"git-commit":' ',"git-merge":' ',"git-pull-request":' ',github:' ',gitlab:' ',globe:' ',grid:' ',"hard-drive":' ',hash:' ',headphones:' ',heart:' ',"help-circle":' ',hexagon:' ',home:' ',image:' ',inbox:' ',info:' ',instagram:' ',italic:' ',key:' ',layers:' ',layout:' ',"life-buoy":' ',"link-2":' ',link:' ',linkedin:' ',list:' ',loader:' ',lock:' ',"log-in":' ',"log-out":' ',mail:' ',"map-pin":' ',map:' ',"maximize-2":' ',maximize:' ',meh:' ',menu:' ',"message-circle":' ',"message-square":' ',"mic-off":' ',mic:' ',"minimize-2":' ',minimize:' ',"minus-circle":' ',"minus-square":' ',minus:' ',monitor:' ',moon:' ',"more-horizontal":' ',"more-vertical":' ',"mouse-pointer":' ',move:' ',music:' ',"navigation-2":' ',navigation:' ',octagon:' ',package:' ',paperclip:' ',"pause-circle":' ',pause:' ',"pen-tool":' ',percent:' ',"phone-call":' ',"phone-forwarded":' ',"phone-incoming":' ',"phone-missed":' ',"phone-off":' ',"phone-outgoing":' ',phone:' ',"pie-chart":' ',"play-circle":' ',play:' ',"plus-circle":' ',"plus-square":' ',plus:' ',pocket:' ',power:' ',printer:' ',radio:' ',"refresh-ccw":' ',"refresh-cw":' ',repeat:' ',rewind:' ',"rotate-ccw":' ',"rotate-cw":' ',rss:' ',save:' ',scissors:' ',search:' ',send:' ',server:' ',settings:' ',"share-2":' ',share:' ',"shield-off":' ',shield:' ',"shopping-bag":' ',"shopping-cart":' ',shuffle:' ',sidebar:' ',"skip-back":' ',"skip-forward":' ',slack:' ',slash:' ',sliders:' ',smartphone:' ',smile:' ',speaker:' ',square:' ',star:' ',"stop-circle":' ',sun:' ',sunrise:' ',sunset:' ',table:' ',tablet:' ',tag:' ',target:' ',terminal:' ',thermometer:' ',"thumbs-down":' ',"thumbs-up":' ',"toggle-left":' ',"toggle-right":' ',tool:' ',"trash-2":' ',trash:' ',trello:' ',"trending-down":' ',"trending-up":' ',triangle:' ',truck:' ',tv:' ',twitch:' ',twitter:' ',type:' ',umbrella:' ',underline:' ',unlock:' ',"upload-cloud":' ',upload:' ',"user-check":' ',"user-minus":' ',"user-plus":' ',"user-x":' ',user:' ',users:' ',"video-off":' ',video:' ',voicemail:' ',"volume-1":' ',"volume-2":' ',"volume-x":' ',volume:' ',watch:' ',"wifi-off":' ',wifi:' ',wind:' ',"x-circle":' ',"x-octagon":' ',"x-square":' ',x:' ',youtube:' ',"zap-off":' ',zap:' ',"zoom-in":' ',"zoom-out":' '}},function(e){e.exports={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"}},function(e,n,i){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var t=Object.assign||function(e){for(var n=1;n2&&void 0!==arguments[2]?arguments[2]:[];!function(e,n){if(!(e instanceof n))throw new TypeError("Cannot call a class as a function")}(this,e),this.name=n,this.contents=i,this.tags=l,this.attrs=t({},o.default,{class:"feather feather-"+n})}return l(e,[{key:"toSvg",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return""+this.contents+" "}},{key:"toString",value:function(){return this.contents}}]),e}();n.default=c},function(e,n,i){"use strict";var t=o(i(12)),l=o(i(39)),r=o(i(38));function o(e){return e&&e.__esModule?e:{default:e}}e.exports={icons:t.default,toSvg:l.default,replace:r.default}},function(e,n,i){e.exports=i(0)},function(e,n,i){var t=i(2)("iterator"),l=!1;try{var r=0,o={next:function(){return{done:!!r++}},return:function(){l=!0}};o[t]=function(){return this},Array.from(o,function(){throw 2})}catch(e){}e.exports=function(e,n){if(!n&&!l)return!1;var i=!1;try{var r={};r[t]=function(){return{next:function(){return{done:i=!0}}}},e(r)}catch(e){}return i}},function(e,n,i){var t=i(30),l=i(2)("toStringTag"),r="Arguments"==t(function(){return arguments}());e.exports=function(e){var n,i,o;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(i=function(e,n){try{return e[n]}catch(e){}}(n=Object(e),l))?i:r?t(n):"Object"==(o=t(n))&&"function"==typeof n.callee?"Arguments":o}},function(e,n,i){var t=i(47),l=i(9),r=i(2)("iterator");e.exports=function(e){if(void 0!=e)return e[r]||e["@@iterator"]||l[t(e)]}},function(e,n,i){"use strict";var t=i(18),l=i(7),r=i(10);e.exports=function(e,n,i){var o=t(n);o in e?l.f(e,o,r(0,i)):e[o]=i}},function(e,n,i){var t=i(2),l=i(9),r=t("iterator"),o=Array.prototype;e.exports=function(e){return void 0!==e&&(l.Array===e||o[r]===e)}},function(e,n,i){var t=i(3);e.exports=function(e,n,i,l){try{return l?n(t(i)[0],i[1]):n(i)}catch(n){var r=e.return;throw void 0!==r&&t(r.call(e)),n}}},function(e,n){e.exports=function(e){if("function"!=typeof e)throw TypeError(String(e)+" is not a function");return e}},function(e,n,i){var t=i(52);e.exports=function(e,n,i){if(t(e),void 0===n)return e;switch(i){case 0:return function(){return e.call(n)};case 1:return function(i){return e.call(n,i)};case 2:return function(i,t){return e.call(n,i,t)};case 3:return function(i,t,l){return e.call(n,i,t,l)}}return function(){return e.apply(n,arguments)}}},function(e,n,i){"use strict";var t=i(53),l=i(24),r=i(51),o=i(50),a=i(27),c=i(49),p=i(48);e.exports=function(e){var n,i,y,h,x=l(e),s="function"==typeof this?this:Array,u=arguments.length,d=u>1?arguments[1]:void 0,f=void 0!==d,g=0,v=p(x);if(f&&(d=t(d,u>2?arguments[2]:void 0,2)),void 0==v||s==Array&&o(v))for(i=new s(n=a(x.length));n>g;g++)c(i,g,f?d(x[g],g):x[g]);else for(h=v.call(x),i=new s;!(y=h.next()).done;g++)c(i,g,f?r(h,d,[y.value,g],!0):y.value);return i.length=g,i}},function(e,n,i){var t=i(32),l=i(54);t({target:"Array",stat:!0,forced:!i(46)(function(e){Array.from(e)})},{from:l})},function(e,n,i){var t=i(6),l=i(3);e.exports=function(e,n){if(l(e),!t(n)&&null!==n)throw TypeError("Can't set "+String(n)+" as a prototype")}},function(e,n,i){var t=i(56);e.exports=Object.setPrototypeOf||("__proto__"in{}?function(){var e,n=!1,i={};try{(e=Object.getOwnPropertyDescriptor(Object.prototype,"__proto__").set).call(i,[]),n=i instanceof Array}catch(e){}return function(i,l){return t(i,l),n?e.call(i,l):i.__proto__=l,i}}():void 0)},function(e,n,i){var t=i(0).document;e.exports=t&&t.documentElement},function(e,n,i){var t=i(28),l=i(13);e.exports=Object.keys||function(e){return t(e,l)}},function(e,n,i){var t=i(8),l=i(7),r=i(3),o=i(59);e.exports=t?Object.defineProperties:function(e,n){r(e);for(var i,t=o(n),a=t.length,c=0;a>c;)l.f(e,i=t[c++],n[i]);return e}},function(e,n,i){var t=i(3),l=i(60),r=i(13),o=i(15),a=i(58),c=i(34),p=i(16)("IE_PROTO"),y=function(){},h=function(){var e,n=c("iframe"),i=r.length;for(n.style.display="none",a.appendChild(n),n.src=String("javascript:"),(e=n.contentWindow.document).open(),e.write("
+
+
+
+ {% if socket_io_enabled %}
+
+
+ {% endif %}
+
+
+
+
+
+
+ {% if hosted_sticky %}
+
+ {% endif %}
+ {% if left_sticky %}
+
+ {% endif %}
+ {% if right_sticky %}
+ {{ right_sticky }}
+ {% endif %}
+
+
+
+
Try our Chrome extension
+
+
+
+ Chrome Webstore
+
+
+ Easily add the current web-page from your browser directly into your changedetection.io tool, more great features coming soon!
+
Changedetection.io needs your support!
+
+ You can help us by supporting changedetection.io on these platforms;
+
+
+
+
+ The more popular changedetection.io is, the more time we can dedicate to adding amazing features!
+
+
+ Many thanks :)
+
+
+ changedetection.io team
+
+
+
+
+ {% block header %}{% endblock %}
+
+
+ {% with messages = get_flashed_messages(with_categories = true) %}
+ {% if
+ messages %}
+
+ {% for category, message in messages %}
+ {{ message }}
+ {% endfor %}
+
+ {% endif %}
+ {% endwith %}
+ {% if session['share-link'] %}
+
+
+ Share this link:
+ {{ session['share-link'] }}
+
+
+
+ {% endif %}
+ {% block content %}{% endblock %}
+
+
+
+ Checking now
+ Offline
+
+
+