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 + +[Self-hosted web page change monitoring, list of websites with changes](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) + +[Select parts and elements of a web page to monitor for changes](https://changedetection.io?src=pip) + +### Easily see what changed, examine by word, line, or individual character. + +[Self-hosted web page change monitoring context difference ](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. + +[Website change detection with interactive browser steps, detect changes behind login and password, search queries and more](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 <title> as watch title was enabled, but couldn't find a <title>.") + + 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/<string:filename>", 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 %} + <div class="edit-form"> + <div class="box-wrap inner"> + <h4>Backups</h4> + {% if backup_running %} + <p> + <strong>A backup is running!</strong> + </p> + {% endif %} + <p> + Here you can download and request a new backup, when a backup is completed you will see it listed below. + </p> + <br> + {% if available_backups %} + <ul> + {% for backup in available_backups %} + <li><a href="{{ url_for('backups.download_backup', filename=backup["filename"]) }}">{{ backup["filename"] }}</a> {{ backup["filesize"] }} Mb</li> + {% endfor %} + </ul> + {% else %} + <p> + <strong>No backups found.</strong> + </p> + {% endif %} + + <a class="pure-button pure-button-primary" href="{{ url_for('backups.request_backup') }}">Create backup</a> + {% if available_backups %} + <a class="pure-button button-small button-error " href="{{ url_for('backups.remove_backups') }}">Remove backups</a> + {% endif %} + </div> + </div> + + +{% 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', + '<select> 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("/<string:uuid>/status", methods=['GET']) + def get_recheck_status(uuid): + results = _recalc_check_status(uuid=uuid) + return results + + @login_required + @check_proxies_blueprint.route("/<string:uuid>/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 <code>https://example.com tag1, tag2, last tag</code> 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 %} +<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> +<div class="edit-form monospaced-textarea"> + + <div class="tabs collapsable"> + <ul> + <li class="tab" id=""><a href="#url-list">URL List</a></li> + <li class="tab"><a href="#distill-io">Distill.io</a></li> + <li class="tab"><a href="#xlsx">.XLSX & Wachete</a></li> + </ul> + </div> + + <div class="box-wrap inner"> + <form class="pure-form" action="{{url_for('imports.import_page')}}" method="POST" enctype="multipart/form-data"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> + <div class="tab-pane-inner" id="url-list"> + <div class="pure-control-group"> + Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma + (,): + <br> + <p><strong>Example: </strong><code>https://example.com tag1, tag2, last tag</code></p> + URLs which do not pass validation will stay in the textarea. + </div> + {{ render_field(form.processor, class="processor") }} + + <div class="pure-control-group"> + <textarea name="urls" class="pure-input-1-2" placeholder="https://" + style="width: 100%; + font-family:monospace; + white-space: pre; + overflow-wrap: normal; + overflow-x: scroll;" rows="25">{{ import_url_list_remaining }}</textarea> + </div> + <div id="quick-watch-processor-type"></div> + + </div> + + <div class="tab-pane-inner" id="distill-io"> + + + + <div class="pure-control-group"> + Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.<br> + This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, the rest (including <code>schedule</code>) are ignored. + <br> + <p> + How to export? <a href="https://distill.io/docs/web-monitor/how-export-and-import-monitors/">https://distill.io/docs/web-monitor/how-export-and-import-monitors/</a><br> + Be sure to set your default fetcher to Chrome if required.<br> + </p> + </div> + + + <textarea name="distill-io" class="pure-input-1-2" style="width: 100%; + font-family:monospace; + white-space: pre; + overflow-wrap: normal; + overflow-x: scroll;" placeholder="Example Distill.io JSON export file + +{ + "client": { + "local": 1 + }, + "data": [ + { + "name": "Unraid | News", + "uri": "https://unraid.net/blog", + "config": "{\"selections\":[{\"frames\":[{\"index\":0,\"excludes\":[],\"includes\":[{\"type\":\"xpath\",\"expr\":\"(//div[@id='App']/div[contains(@class,'flex')]/main[contains(@class,'relative')]/section[contains(@class,'relative')]/div[@class='container']/div[contains(@class,'flex')]/div[contains(@class,'w-full')])[1]\"}]}],\"dynamic\":true,\"delay\":2}],\"ignoreEmptyText\":true,\"includeStyle\":false,\"dataAttr\":\"text\"}", + "tags": [], + "content_type": 2, + "state": 40, + "schedule": "{\"type\":\"INTERVAL\",\"params\":{\"interval\":4447}}", + "ts": "2022-03-27T15:51:15.667Z" + } + ] +} +" rows="25">{{ original_distill_json }}</textarea> + + </div> + <div class="tab-pane-inner" id="xlsx"> + <fieldset> + <div class="pure-control-group"> + {{ render_field(form.xlsx_file, class="processor") }} + </div> + <div class="pure-control-group"> + {{ render_field(form.file_mapping, class="processor") }} + </div> + </fieldset> + <div class="pure-control-group"> + <span class="pure-form-message-inline"> + Table of custom column and data types mapping for the <strong>Custom mapping</strong> File mapping type. + </span> + <table style="border: 1px solid #aaa; padding: 0.5rem; border-radius: 4px;"> + <tr> + <td><strong>Column #</strong></td> + {% for n in range(4) %} + <td><input type="number" name="custom_xlsx[col_{{n}}]" style="width: 4rem;" min="1"></td> + {% endfor %} + </tr> + <tr> + <td><strong>Type</strong></td> + {% for n in range(4) %} + <td><select name="custom_xlsx[col_type_{{n}}]"> + <option value="" style="color: #aaa"> -- none --</option> + <option value="url">URL</option> + <option value="title">Title</option> + <option value="include_filters">CSS/xPath filter</option> + <option value="tag">Group / Tag name(s)</option> + <option value="interval_minutes">Recheck time (minutes)</option> + </select></td> + {% endfor %} + </tr> + </table> + </div> + </div> + <button type="submit" class="pure-button pure-input-1-2 pure-button-primary">Import</button> + </form> + + </div> +</div> + +{% 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("/<string:uuid>/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("/<string:uuid>/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="<br>", + 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 <link> goes to the diff page, or to the watch link + rss_template = "<html><body>\n<h4><a href=\"{{watch_url}}\">{{watch_title}}</a></h4>\n<p>{{html_diff}}</p>\n</body></html>\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 %} +<div class="edit-form"> + <div class="inner"> + + <h4 style="margin-top: 0px;">Notification debug log</h4> + <div id="notification-error-log"> + <ul style="font-size: 80%; margin:0px; padding: 0 0 0 7px"> + {% for log in logs|reverse %} + <li>{{log}}</li> + {% endfor %} + </ul> + </div> + + </div> +</div> + +{% 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 %} +<script> + const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="global-settings")}}"; +{% if emailprefix %} + const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}'); +{% endif %} +</script> +<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> +<script src="{{url_for('static_content', group='js', filename='plugins.js')}}" defer></script> +<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> +<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script> +<script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script> +<script src="{{url_for('static_content', group='js', filename='scheduler.js')}}" defer></script> +<div class="edit-form"> + <div class="tabs collapsable"> + <ul> + <li class="tab" id=""><a href="#general">General</a></li> + <li class="tab"><a href="#notifications">Notifications</a></li> + <li class="tab"><a href="#fetching">Fetching</a></li> + <li class="tab"><a href="#filters">Global Filters</a></li> + <li class="tab"><a href="#ui-options">UI Options</a></li> + <li class="tab"><a href="#api">API</a></li> + <li class="tab"><a href="#timedate">Time & Date</a></li> + <li class="tab"><a href="#proxies">CAPTCHA & Proxies</a></li> + </ul> + </div> + <div class="box-wrap inner"> + <form class="pure-form pure-form-stacked settings" action="{{url_for('settings.settings_page')}}" method="POST"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > + <div class="tab-pane-inner" id="general"> + <fieldset> + <div class="pure-control-group"> + {{ render_field(form.requests.form.time_between_check, class="time-check-widget") }} + <span class="pure-form-message-inline">Default recheck time for all watches, current system minimum is <i>{{min_system_recheck_seconds}}</i> seconds (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Misc-system-settings#enviroment-variables">more info</a>).</span> + <div id="time-between-check-schedule"> + <!-- Start Time and End Time --> + <div id="limit-between-time"> + {{ render_time_schedule_form(form.requests, available_timezones, timezone_default_config) }} + </div> + </div> + </div> + <div class="pure-control-group"> + {{ render_field(form.requests.form.jitter_seconds, class="jitter_seconds") }} + <span class="pure-form-message-inline">Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later</span> + </div> + <div class="pure-control-group"> + {{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class="filter_failure_notification_threshold_attempts") }} + <span class="pure-form-message-inline">After this many consecutive times that the CSS/xPath filter is missing, send a notification + <br> + Set to <strong>0</strong> to disable + </span> + </div> + <div class="pure-control-group"> + {% if not hide_remove_pass %} + {% if current_user.is_authenticated %} + {{ render_button(form.application.form.removepassword_button) }} + {% else %} + {{ render_field(form.application.form.password) }} + <span class="pure-form-message-inline">Password protection for your changedetection.io application.</span> + {% endif %} + {% else %} + <span class="pure-form-message-inline">Password is locked.</span> + {% endif %} + </div> + + <div class="pure-control-group"> + {{ render_checkbox_field(form.application.form.shared_diff_access, class="shared_diff_access") }} + <span class="pure-form-message-inline">Allow access to view watch diff page when password is enabled (Good for sharing the diff page) + </span> + </div> + <div class="pure-control-group"> + {{ render_checkbox_field(form.application.form.rss_hide_muted_watches) }} + </div> + <div class="pure-control-group"> + {{ render_field(form.application.form.pager_size) }} + <span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span> + </div> + <div class="pure-control-group"> + {{ render_field(form.application.form.rss_content_format) }} + <span class="pure-form-message-inline">Love RSS? Does your reader support HTML? Set it here</span> + </div> + <div class="pure-control-group"> + {{ render_checkbox_field(form.application.form.extract_title_as_title) }} + <span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span> + </div> + <div class="pure-control-group"> + {{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }} + <span class="pure-form-message-inline">When a request returns no content, or the HTML does not contain any text, is this considered a change?</span> + </div> + {% if form.requests.proxy %} + <div class="pure-control-group inline-radio"> + {{ render_field(form.requests.form.proxy, class="fetch-backend-proxy") }} + <span class="pure-form-message-inline"> + Choose a default proxy for all watches + </span> + </div> + {% endif %} + </fieldset> + </div> + + <div class="tab-pane-inner" id="notifications"> + <fieldset> + <div class="field-group"> + {{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }} + </div> + </fieldset> + <div class="pure-control-group" id="notification-base-url"> + {{ render_field(form.application.form.base_url, class="m-d") }} + <span class="pure-form-message-inline"> + Base URL used for the <code>{{ '{{ base_url }}' }}</code> token in notification links.<br> + Default value is the system environment variable '<code>BASE_URL</code>' - <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>. + </span> + </div> + </div> + + <div class="tab-pane-inner" id="fetching"> + <div class="pure-control-group inline-radio"> + {{ render_field(form.application.form.fetch_backend, class="fetch-backend") }} + <span class="pure-form-message-inline"> + <p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p> + <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> + </span> + </div> + <fieldset class="pure-group" id="webdriver-override-options" data-visible-for="application-fetch_backend=html_webdriver"> + <div class="pure-form-message-inline"> + <strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong> + <br> + This will wait <i>n</i> seconds before extracting the text. + </div> + <div class="pure-control-group"> + {{ render_field(form.application.form.webdriver_delay) }} + </div> + </fieldset> + <div class="pure-control-group"> + {{ render_field(form.requests.form.workers) }} + {% set worker_info = get_worker_status_info() %} + <span class="pure-form-message-inline">Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.<br> + Currently running: <strong>{{ worker_info.count }}</strong> operational {{ worker_info.type }} workers{% if worker_info.active_workers > 0 %} ({{ worker_info.active_workers }} actively processing){% endif %}.</span> + </div> + <div class="pure-control-group inline-radio"> + {{ render_field(form.requests.form.default_ua) }} + <span class="pure-form-message-inline"> + Applied to all requests.<br><br> + Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider <a href="https://changedetection.io/tutorial/what-are-main-types-anti-robot-mechanisms">all of the ways that the browser is detected</a>. + </span> + </div> + <div class="pure-control-group"> + <br> + Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a> + + </div> + </div> + + <div class="tab-pane-inner" id="filters"> + + <fieldset class="pure-group"> + {{ render_checkbox_field(form.application.form.ignore_whitespace) }} + <span class="pure-form-message-inline">Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.<br> + <i>Note:</i> Changing this will change the status of your existing watches, possibly trigger alerts etc. + </span> + </fieldset> + <fieldset class="pure-group"> + {{ render_checkbox_field(form.application.form.render_anchor_tag_content) }} + <span class="pure-form-message-inline">Render anchor tag content, default disabled, when enabled renders links as <code>(link text)[https://somesite.com]</code> + <br> + <i>Note:</i> Changing this could affect the content of your existing watches, possibly trigger alerts etc. + </span> + </fieldset> + <fieldset class="pure-group"> + {{ render_field(form.application.form.global_subtractive_selectors, rows=5, placeholder="header +footer +nav +.stockticker +//*[contains(text(), 'Advertisement')]") }} + <span class="pure-form-message-inline"> + <ul> + <li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li> + <li> Don't paste HTML here, use only CSS and XPath selectors </li> + <li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li> + </ul> + </span> + </fieldset> + <fieldset class="pure-group"> + {{ render_field(form.application.form.global_ignore_text, rows=5, placeholder="Some text to ignore in a line +/some.regex\d{2}/ for case-INsensitive regex + ") }} + <span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br> + <span class="pure-form-message-inline"> + <ul> + <li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li> + <li>Note: This is applied globally in addition to the per-watch rules.</li> + <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> + <li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li> + <li>Changing this will affect the comparison checksum which may trigger an alert</li> + </ul> + </span> + </fieldset> + </div> + + <div class="tab-pane-inner" id="api"> + <h4>API Access</h4> + <p>Drive your changedetection.io via API, More about <a href="https://github.com/dgtlmoon/changedetection.io/wiki/API-Reference">API access here</a></p> + + <div class="pure-control-group"> + {{ render_checkbox_field(form.application.form.api_access_token_enabled) }} + <div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header - required for the Chrome Extension to work</div><br> + <div class="pure-form-message-inline"><br>API Key <span id="api-key">{{api_key}}</span> + <span style="display:none;" id="api-key-copy" >copy</span> + </div> + </div> + <div class="pure-control-group"> + <a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">Regenerate API key</a> + </div> + <div class="pure-control-group"> + <h4>Chrome Extension</h4> + <p>Easily add any web-page to your changedetection.io installation from within Chrome.</p> + <strong>Step 1</strong> Install the extension, <strong>Step 2</strong> Navigate to this page, + <strong>Step 3</strong> Open the extension from the toolbar and click "<i>Sync API Access</i>" + <p> + <a id="chrome-extension-link" + title="Try our new Chrome Extension!" + href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop"> + <img alt="Chrome store icon" src="{{ url_for('static_content', group='images', filename='google-chrome-icon.png') }}" alt="Chrome"> + Chrome Webstore + </a> + </p> + </div> + </div> + <div class="tab-pane-inner" id="timedate"> + <div class="pure-control-group"> + Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches. + </div> + <div class="pure-control-group"> + <p><strong>UTC Time & Date from Server:</strong> <span id="utc-time" >{{ utc_time }}</span></p> + <p><strong>Local Time & Date in Browser:</strong> <span class="local-time" data-utc="{{ utc_time }}"></span></p> + <p> + {{ render_field(form.application.form.timezone) }} + <datalist id="timezones" style="display: none;"> + {% for tz_name in available_timezones %} + <option value="{{ tz_name }}">{{ tz_name }}</option> + {% endfor %} + </datalist> + </p> + </div> + </div> + <div class="tab-pane-inner" id="ui-options"> + <div class="pure-control-group"> + {{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class="open_diff_in_new_tab") }} + <span class="pure-form-message-inline">Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.</span> + </div> + <div class="pure-control-group"> + {{ render_checkbox_field(form.application.form.ui.form.socket_io_enabled, class="socket_io_enabled") }} + <span class="pure-form-message-inline">Realtime UI Updates Enabled - (Restart required if this is changed)</span> + </div> + </div> + <div class="tab-pane-inner" id="proxies"> + <div id="recommended-proxy"> + <div> + <img style="height: 2em;" src="{{url_for('static_content', group='images', filename='brightdata.svg')}}" alt="BrightData Proxy Provider"> + <p>BrightData offer world-class proxy services, "Data Center" proxies are a very affordable way to proxy your requests, whilst <strong><a href="https://brightdata.grsm.io/n0r16zf7eivq">WebUnlocker</a></strong> can help solve most CAPTCHAs.</p> + <p> + BrightData offer many <a href="https://brightdata.com/proxy-types" target="new">many different types of proxies</a>, it is worth reading about what is best for your use-case. + </p> + + <p> + When you have <a href="https://brightdata.grsm.io/n0r16zf7eivq">registered</a>, enabled the required services, visit the <A href="https://brightdata.com/cp/api_example?">API example page</A>, then select <strong>Python</strong>, set the country you wish to use, then copy+paste the access Proxy URL into the "Extra Proxies" boxes below.<br> + </p> + <p> + The Proxy URL with BrightData should start with <code>http://brd-customer...</code> + </p> + <p>When you sign up using <a href="https://brightdata.grsm.io/n0r16zf7eivq">https://brightdata.grsm.io/n0r16zf7eivq</a> BrightData will match any first deposit up to $150</p> + </div> + <div> + <img style="height: 2em;" + src="{{url_for('static_content', group='images', filename='oxylabs.svg')}}" + alt="Oxylabs Proxy Provider"> + <p> + Collect public data at scale with industry-leading web scraping solutions and the world’s + largest ethical proxy network. + </p> + <p> + Oxylabs also provide a <a href="https://oxylabs.io/products/web-unblocker"><strong>WebUnlocker</strong></a> + proxy that bypasses sophisticated anti-bot systems, so you don’t have to.<br> + </p> + <p> + Serve over <a href="https://oxylabs.io/location-proxy">195 countries</a>, providing <a + href="https://oxylabs.io/products/residential-proxy-pool">Residential</a>, <a + href="https://oxylabs.io/products/mobile-proxies">Mobile</a> and <a + href="https://oxylabs.io/products/rotating-isp-proxies">ISP proxies</a> and much more. + </p> + <p> + Use the promo code <strong>boost35</strong> with this link <a href="https://oxylabs.go2cloud.org/SH2d">https://oxylabs.go2cloud.org/SH2d</a> for 35% off Residential, Mobile proxies, Web Unblocker, and Scraper APIs. Built-in proxies enable you to access data from all around the world and help overcome anti-bot solutions. + + </p> + + + </div> + </div> + + <p><strong>Tip</strong>: "Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites. + + <div class="pure-control-group" id="extra-proxies-setting"> + {{ render_field(form.requests.form.extra_proxies) }} + <span class="pure-form-message-inline">"Name" will be used for selecting the proxy in the Watch Edit settings</span><br> + <span class="pure-form-message-inline">SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should whitelist the IP access instead</span> + </div> + <div class="pure-control-group" id="extra-browsers-setting"> + <p> + <span class="pure-form-message-inline"><i>Extra Browsers</i> can be attached to further defeat CAPTCHA's on websites that are particularly hard to scrape.</span><br> + <span class="pure-form-message-inline">Simply paste the connection address into the box, <a href="https://changedetection.io/tutorial/using-bright-datas-scraping-browser-pass-captchas-and-other-protection-when-monitoring">More instructions and examples here</a> </span> + </p> + {{ render_field(form.requests.form.extra_browsers) }} + </div> + </div> + <div id="actions"> + <div class="pure-control-group"> + {{ render_button(form.save_button) }} + <a href="{{url_for('watchlist.index')}}" class="pure-button button-small button-cancel">Back</a> + <a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a> + </div> + </div> + </form> + </div> +</div> + +{% 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/<string:uuid>", 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/<string:uuid>", 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/<string:uuid>", 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/<string:uuid>", 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 %} + <script> + $(document).ready(function () { + toggleOpacity('#overrides_watch', '#restock-fieldset-price-group', true); + }); + </script> + <fieldset> + <div class="pure-control-group"> + <fieldset class="pure-group"> + {{ render_checkbox_field(form.overrides_watch) }} + <span class="pure-form-message-inline">Used for watches in "Restock & Price detection" mode</span> + </fieldset> + </fieldset> + """ + 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/<string:uuid>", 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/<string:uuid>", 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 %} +<script> + const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="group-settings")}}"; +</script> + +<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> +<script> + +/*{% if emailprefix %}*/ + /*const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}');*/ +/*{% endif %}*/ + +{% set has_tag_filters_extra='' %} + +</script> + +<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script> +<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> + +<div class="edit-form monospaced-textarea"> + + <div class="tabs collapsable"> + <ul> + <li class="tab" id=""><a href="#general">General</a></li> + <li class="tab"><a href="#filters-and-triggers">Filters & Triggers</a></li> + {% if extra_tab_content %} + <li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li> + {% endif %} + <li class="tab"><a href="#notifications">Notifications</a></li> + </ul> + </div> + + <div class="box-wrap inner"> + <form class="pure-form pure-form-stacked" + action="{{ url_for('tags.form_tag_edit', uuid=data.uuid) }}" method="POST"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> + + <div class="tab-pane-inner" id="general"> + <fieldset> + <div class="pure-control-group"> + {{ render_field(form.title, placeholder="https://...", required=true, class="m-d") }} + </div> + </fieldset> + </div> + + <div class="tab-pane-inner" id="filters-and-triggers"> + <p>These settings are <strong><i>added</i></strong> to any existing watch configurations.</p> + {% include "edit/include_subtract.html" %} + <div class="text-filtering border-fieldset"> + <h3>Text filtering</h3> + {% include "edit/text-options.html" %} + </div> + </div> + + {# rendered sub Template #} + {% if extra_form_content %} + <div class="tab-pane-inner" id="extras_tab"> + {{ extra_form_content|safe }} + </div> + {% endif %} + <div class="tab-pane-inner" id="notifications"> + <fieldset> + <div class="pure-control-group inline-radio"> + {{ render_checkbox_field(form.notification_muted) }} + </div> + {% if 1 %} + <div class="pure-control-group inline-radio"> + {{ render_checkbox_field(form.notification_screenshot) }} + <span class="pure-form-message-inline"> + <strong>Use with caution!</strong> This will easily fill up your email storage quota or flood other storages. + </span> + </div> + {% endif %} + <div class="field-group" id="notification-field-group"> + {% if has_default_notification_urls %} + <div class="inline-warning"> + <img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!" > + There are <a href="{{ url_for('settings.settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications. + </div> + {% endif %} + <a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">Use system defaults</a> + + {{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }} + </div> + </fieldset> + </div> + + <div id="actions"> + <div class="pure-control-group"> + {{ render_button(form.save_button) }} + </div> + </div> + </form> + </div> +</div> + +{% 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 %} +<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> + +<div class="box"> + <form class="pure-form" action="{{ url_for('tags.form_tag_add') }}" method="POST" id="new-watch-form"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > + <fieldset> + <legend>Add a new organisational tag</legend> + <div id="watch-add-wrapper-zone"> + <div> + {{ render_simple_field(form.name, placeholder="watch label / tag") }} + </div> + <div> + {{ render_simple_field(form.save_button, title="Save" ) }} + </div> + </div> + <br> + <div style="color: #fff;">Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.</div> + </fieldset> + </form> + <!-- @todo maybe some overview matrix, 'tick' with which has notification, filter rules etc --> + <div id="watch-table-wrapper"> + + <table class="pure-table pure-table-striped watch-table group-overview-table"> + <thead> + <tr> + <th></th> + <th># Watches</th> + <th>Tag / Label name</th> + <th></th> + </tr> + </thead> + <tbody> + <!-- + @Todo - connect Last checked, Last Changed, Number of Watches etc + ---> + {% if not available_tags|length %} + <tr> + <td colspan="3">No website organisational tags/groups configured</td> + </tr> + {% endif %} + {% for uuid, tag in available_tags %} + <tr id="{{ uuid }}" class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }}"> + <td class="watch-controls"> + <a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a> + </td> + <td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td> + <td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}">{{ tag.title }}</a></td> + <td> + <a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">Edit</a>  + <a class="pure-button pure-button-primary" href="{{ url_for('tags.delete', uuid=uuid) }}" title="Deletes and removes tag">Delete</a> + <a class="pure-button pure-button-primary" href="{{ url_for('tags.unlink', uuid=uuid) }}" title="Keep the tag but unlink any watches">Unlink</a> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> +</div> +{% 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/<string:uuid>", 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/<string:uuid>", 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/<string:uuid>", 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/<string:uuid>/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/<string:uuid>/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"<a href={url_for('ui.ui_views.preview_page', uuid=uuid)}>Click to preview</a>" + + 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/<string:watch_uuid>", 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 - <class 'apprise.decorators.base.CustomNotifyPlugin.instantiate_plugin.<locals>.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 %} +<div class="edit-form"> + <div class="box-wrap inner"> + <form + class="pure-form pure-form-stacked" + action="{{url_for('ui.clear_all_history')}}" + method="POST" + > + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > + <fieldset> + <div class="pure-control-group"> + This will remove version history (snapshots) for ALL watches, but keep + your list of URLs! <br /> + You may like to use the <strong>BACKUP</strong> link first.<br /> + </div> + <br /> + <div class="pure-control-group"> + <label for="confirmtext">Confirmation text</label> + <input + type="text" + id="confirmtext" + required="" + name="confirmtext" + value="" + size="10" + /> + <span class="pure-form-message-inline" + >Type in the word <strong>clear</strong> to confirm that you + understand.</span + > + </div> + <br /> + <div class="pure-control-group"> + <button type="submit" class="pure-button pure-button-primary"> + Clear History! + </button> + </div> + <br /> + <div class="pure-control-group"> + <a href="{{url_for('watchlist.index')}}" class="pure-button button-cancel" + >Cancel</a + > + </div> + </fieldset> + </form> + </div> +</div> + +{% 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/<string:uuid>", 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/<string:uuid>", 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 -%} +<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> +<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script> +<script>let nowtimeserver={{ now_time_server }};</script> +<script> +// Initialize Feather icons after the page loads +document.addEventListener('DOMContentLoaded', function() { + feather.replace(); +}); +</script> +<style> +.checking-now .last-checked { + background-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.05) 40%, rgba(0,0,0,0.1) 100%); + background-size: 0 100%; + background-repeat: no-repeat; + transition: background-size 0.9s ease +} +</style> +<div class="box"> + + <form class="pure-form" action="{{ url_for('ui.ui_views.form_quick_watch_add', tag=active_tag_uuid) }}" method="POST" id="new-watch-form"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > + <fieldset> + <legend>Add a new change detection watch</legend> + <div id="watch-add-wrapper-zone"> + + {{ 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") }} + </div> + <div id="quick-watch-processor-type"> + {{ render_simple_field(form.processor) }} + </div> + + </fieldset> + <span style="color:#eee; font-size: 80%;"><img alt="Create a shareable link" style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" > Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></span> + </form> + + <form class="pure-form" action="{{ url_for('ui.form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > + <input type="hidden" id="op_extradata" name="op_extradata" value="" > + <div id="checkbox-operations"> + <button class="pure-button button-secondary button-xsmall" name="op" value="pause"><i data-feather="pause" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Pause</button> + <button class="pure-button button-secondary button-xsmall" name="op" value="unpause"><i data-feather="play" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>UnPause</button> + <button class="pure-button button-secondary button-xsmall" name="op" value="mute"><i data-feather="volume-x" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Mute</button> + <button class="pure-button button-secondary button-xsmall" name="op" value="unmute"><i data-feather="volume-2" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>UnMute</button> + <button class="pure-button button-secondary button-xsmall" name="op" value="recheck"><i data-feather="refresh-cw" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Recheck</button> + <button class="pure-button button-secondary button-xsmall" name="op" value="assign-tag" id="checkbox-assign-tag"><i data-feather="tag" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Tag</button> + <button class="pure-button button-secondary button-xsmall" name="op" value="mark-viewed"><i data-feather="eye" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Mark viewed</button> + <button class="pure-button button-secondary button-xsmall" name="op" value="notification-default"><i data-feather="bell" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Use default notification</button> + <button class="pure-button button-secondary button-xsmall" name="op" value="clear-errors"><i data-feather="x-circle" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Clear errors</button> + <button class="pure-button button-secondary button-xsmall" style="background: #dd4242;" name="op" value="clear-history"><i data-feather="trash-2" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Clear/reset history</button> + <button class="pure-button button-secondary button-xsmall" style="background: #dd4242;" name="op" value="delete"><i data-feather="trash" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Delete</button> + </div> + {%- if watches|length >= pagination.per_page -%} + {{ pagination.info }} + {%- endif -%} + {%- if search_q -%}<div id="search-result-info">Searching "<strong><i>{{search_q}}</i></strong>"</div>{%- endif -%} + <div> + <a href="{{url_for('watchlist.index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">All</a> + + <!-- tag list --> + {%- for uuid, tag in tags -%} + {%- if tag != "" -%} + <a href="{{url_for('watchlist.index', tag=uuid) }}" class="pure-button button-tag {{'active' if active_tag_uuid == uuid }}">{{ tag.title }}</a> + {%- endif -%} + {%- endfor -%} + </div> + + {%- 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 -%} + + <div id="watch-table-wrapper"> + + <table class="pure-table pure-table-striped watch-table"> + <thead> + <tr> + {%- set link_order = "desc" if sort_order == 'asc' else "asc" -%} + {%- set arrow_span = "" -%} + <th><input style="vertical-align: middle" type="checkbox" id="check-all" > <a class="{{ 'active '+link_order if sort_attribute == 'date_created' else 'inactive' }}" href="{{url_for('watchlist.index', sort='date_created', order=link_order, tag=active_tag_uuid)}}"># <span class='arrow {{link_order}}'></span></a></th> + <th class="empty-cell"></th> + <th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('watchlist.index', sort='label', order=link_order, tag=active_tag_uuid)}}">Website <span class='arrow {{link_order}}'></span></a></th> + {%- if any_has_restock_price_processor -%} + <th>Restock & Price</th> + {%- endif -%} + <th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('watchlist.index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Checked <span class='arrow {{link_order}}'></span></a></th> + <th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('watchlist.index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Changed <span class='arrow {{link_order}}'></span></a></th> + <th class="empty-cell"></th> + </tr> + </thead> + <tbody> + {%- if not watches|length -%} + <tr> + <td colspan="{{ cols_required }}" style="text-wrap: wrap;">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('imports.import_page')}}" >import a list</a>.</td> + </tr> + {%- 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 '' + ] -%} + <tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}" class="{{ row_classes | reject('equalto', '') | join(' ') }}"> + <td class="inline checkbox-uuid" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td> + <td class="inline watch-controls"> + <a class="ajax-op state-off pause-toggle" data-op="pause" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a> + <a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a> + <a class="ajax-op state-off mute-toggle" data-op="mute" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notification" title="Mute notification" class="icon icon-mute" ></a> + <a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="UnMute notification" title="UnMute notification" class="icon icon-mute" ></a> + </td> + <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} + <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"> </a> + <a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a> + + {%- 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 + -%} + <img class="status-icon" src="{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}" alt="Using a Chrome browser" title="Using a Chrome browser" > + {%- endif -%} + + {%- if watch.is_pdf -%}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" alt="Converting PDF to text" >{%- endif -%} + {%- if watch.has_browser_steps -%}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" alt="Browser Steps is enabled" >{%- endif -%} + + <div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list)|safe }}</div> + + {%- if watch['processor'] == 'text_json_diff' -%} + {%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%} + <div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div> + {%- endif -%} + {%- endif -%} + {%- if watch['processor'] == 'restock_diff' -%} + <span class="tracking-ldjson-price-data" title="Automatically following embedded price information"><img src="{{url_for('static_content', group='images', filename='price-tag-icon.svg')}}" class="status-icon price-follow-tag-icon" > Price</span> + {%- endif -%} + {%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%} + <span class="watch-tag-list">{{ watch_tag.title }}</span> + {%- endfor -%} + </td> +{%- if any_has_restock_price_processor -%} + <td class="restock-and-price"> + {%- if watch['processor'] == 'restock_diff' -%} + {%- if watch.has_restock_info -%} + <span class="restock-label {{'in-stock' if watch['restock']['in_stock'] else 'not-in-stock' }}" title="Detecting restock and price"> + <!-- maybe some object watch['processor'][restock_diff] or.. --> + {%- if watch['restock']['in_stock']-%} In stock {%- else-%} Not in stock {%- endif -%} + </span> + {%- endif -%} + + {%- if watch.get('restock') and watch['restock']['price'] != None -%} + {%- if watch['restock']['price'] != None -%} + <span class="restock-label price" title="Price"> + {{ watch['restock']['price']|format_number_locale }} {{ watch['restock']['currency'] }} + </span> + {%- endif -%} + {%- elif not watch.has_restock_info -%} + <span class="restock-label error">No information</span> + {%- endif -%} + {%- endif -%} + </td> +{%- endif -%} + {#last_checked becomes fetch-start-time#} + <td class="last-checked" data-timestamp="{{ watch.last_checked }}" data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" > + <div class="spinner-wrapper" style="display:none;" > + <span class="spinner"></span><span> Checking now</span> + </div> + <span class="innertext">{{watch|format_last_checked_time|safe}}</span> + </td> + <td class="last-changed" data-timestamp="{{ watch.last_changed }}">{%- if watch.history_n >=2 and watch.last_changed >0 -%} + {{watch.last_changed|format_timestamp_timeago}} + {%- else -%} + Not yet + {%- endif -%} + </td> + <td> + {%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%} + <a href="" class="already-in-queue-button recheck pure-button pure-button-primary" style="display: none;" disabled="disabled">Queued</a> + <a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck pure-button pure-button-primary">Recheck</a> + <a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a> + <a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary history-link" style="display: none;">History</a> + <a href="{{ url_for('ui.ui_views.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary preview-link" style="display: none;">Preview</a> + </td> + </tr> + {%- endfor -%} + </tbody> + </table> + <ul id="post-list-buttons"> + <li id="post-list-with-errors" class="{%- if errored_count -%}has-error{%- endif -%}" style="display: none;" > + <a href="{{url_for('watchlist.index', with_errors=1, tag=request.args.get('tag')) }}" class="pure-button button-tag button-error">With errors ({{ errored_count }})</a> + </li> + <li id="post-list-mark-views" class="{%- if has_unviewed -%}has-unviewed{%- endif -%}" style="display: none;" > + <a href="{{url_for('ui.mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed</a> + </li> + <li> + <a href="{{ url_for('ui.form_watch_checknow', tag=active_tag_uuid, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag" id="recheck-all">Recheck + all {%- if active_tag_uuid-%} in "{{active_tag.title}}"{%endif%}</a> + </li> + <li> + <a href="{{ url_for('rss.feed', tag=active_tag_uuid, token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}" height="15"></a> + </li> + </ul> + {{ pagination.links }} + </div> + </form> +</div> +{%- 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("/<string:watch_uuid>/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 <title>"), # actual page title <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 "<p>Not enough history to calculate Levenshtein metrics</p>" + + try: + lev_data = levenshtein_ratio_recent_history(watch) + if not lev_data or not isinstance(lev_data, dict): + return "<p>Unable to calculate Levenshtein metrics</p>" + + html = f""" + <div class="levenshtein-stats"> + <h4>Levenshtein Text Similarity Details</h4> + <table class="pure-table"> + <tbody> + <tr> + <td>Raw distance (edits needed)</td> + <td>{lev_data['distance']}</td> + </tr> + <tr> + <td>Similarity ratio</td> + <td>{lev_data['ratio']:.4f}</td> + </tr> + <tr> + <td>Percent similar</td> + <td>{lev_data['percent_similar']}%</td> + </tr> + </tbody> + </table> + <p style="font-size: 80%;">Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one into the other.</p> + </div> + """ + return html + except Exception as e: + logger.error(f"Error generating Levenshtein UI extras: {str(e)}") + return "<p>Error calculating Levenshtein metrics</p>" + 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""" + <div class="word-count-stats"> + <h4>Content Analysis</h4> + <table class="pure-table"> + <tbody> + <tr> + <td>Word count (latest snapshot)</td> + <td>{word_count}</td> + </tr> + </tbody> + </table> + <p style="font-size: 80%;">Word count is a simple measure of content length, calculated by splitting text on whitespace.</p> + </div> + """ + 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 <button>OUT OF STOCK</button>. +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), <anonymous>:67:20) at UtilityScript.evaluate (<anonymous>:159:18) at UtilityScript.<anonymous> (<anonymous>: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'<span style="{REMOVED_STYLE}">{line}</span>' 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'<span style="{REMOVED_STYLE}">{line}</span>' for line in same_slicer(before, alo, ahi)] + \ + [f'<span style="{ADDED_STYLE}">{line}</span>' 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'<span style="{ADDED_STYLE}">{line}</span>' 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/<string:uuid>/history/<string:timestamp>', + resource_class_kwargs={'datastore': datastore, 'update_q': update_q}) + + watch_api.add_resource(WatchHistory, + '/api/v1/watch/<string:uuid>/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/<string:uuid>', + 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/<string:uuid>', + 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/<string:group>/<string:filename>", 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 <option data-enabled="enabled-0-0" +from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config + +from changedetectionio import html_tools, content_fetchers + +from changedetectionio.notification import ( + valid_notification_formats, +) + +from wtforms.fields import FormField + +dictfilt = lambda x, y: dict([ (i,x[i]) for i in x if i in set(y) ]) + +valid_method = { + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'OPTIONS', +} + +default_method = 'GET' +allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) + +class StringListField(StringField): + widget = widgets.TextArea() + + def _value(self): + if self.data: + # ignore empty lines in the storage + data = list(filter(lambda x: len(x.strip()), self.data)) + # Apply strip to each line + data = list(map(lambda x: x.strip(), data)) + return "\r\n".join(data) + else: + return u'' + + # incoming + def process_formdata(self, valuelist): + if valuelist and len(valuelist[0].strip()): + # Remove empty strings, stripping and splitting \r\n, only \n etc. + self.data = valuelist[0].splitlines() + # Remove empty lines from the final data + self.data = list(filter(lambda x: len(x.strip()), self.data)) + else: + self.data = [] + + +class SaltyPasswordField(StringField): + widget = widgets.PasswordInput() + encrypted_password = "" + + def build_password(self, password): + import base64 + import hashlib + import secrets + + # Make a new salt on every new password and store it with the password + salt = secrets.token_bytes(32) + + key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) + store = base64.b64encode(salt + key).decode('ascii') + + return store + + # incoming + def process_formdata(self, valuelist): + if valuelist: + # Be really sure it's non-zero in length + if len(valuelist[0].strip()) > 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 <input> 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 += '<br>' + field.gettext( + # 'Be sure that the selenium/webdriver runner is running and accessible via network from this container/host.') + # message += '<br>' + field.gettext('Did you follow the instructions in the wiki?') + # message += '<br><br>' + field.gettext('WebDriver Host: %s' % (driver_url)) + # message += '<br><a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">Go here for more information</a>' + # message += '<br>'+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("<html></html>") + 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("<html></html>") + 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 <title> 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 <script>var.. + selector = StringField('Selector', [validators.Optional()], render_kw={"placeholder": "CSS or xPath selector"}) + optional_value = StringField('value', [validators.Optional()], render_kw={"placeholder": "Value"}) +# @todo move to JS? ajax fetch new field? +# remove_button = SubmitField('-', render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Remove'}) +# add_button = SubmitField('+', render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Add new step after'}) + +class processor_text_json_diff_form(commonSettingsForm): + + url = fields.URLField('URL', validators=[validateURL()]) + tags = StringTagUUID('Group tag', [validators.Optional()], default='') + + time_between_check = FormField(TimeBetweenCheckForm) + + time_schedule_limit = FormField(ScheduleLimitForm) + + time_between_check_use_default = BooleanField('Use global settings for time between check', default=False) + + include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='') + + subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)]) + + extract_text = StringListField('Extract text', [ValidateListRegex()]) + + title = StringField('Title', default='') + + ignore_text = StringListField('Ignore lines containing', [ValidateListRegex()]) + headers = StringDictKeyValue('Request headers') + body = TextAreaField('Request body', [validators.Optional()]) + method = SelectField('Request method', choices=valid_method, default=default_method) + ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False) + check_unique_lines = BooleanField('Only trigger when unique lines appear in all history', default=False) + remove_duplicate_lines = BooleanField('Remove duplicate lines of text', default=False) + sort_text_alphabetically = BooleanField('Sort text alphabetically', default=False) + trim_text_whitespace = BooleanField('Trim whitespace before and after text', default=False) + + filter_text_added = BooleanField('Added lines', default=True) + filter_text_replaced = BooleanField('Replaced/changed lines', default=True) + filter_text_removed = BooleanField('Removed lines', default=True) + + trigger_text = StringListField('Keyword triggers - Trigger/wait for text', [validators.Optional(), ValidateListRegex()]) + if os.getenv("PLAYWRIGHT_DRIVER_URL"): + browser_steps = FieldList(FormField(SingleBrowserStep), min_entries=10) + text_should_not_be_present = StringListField('Block change-detection while text matches', [validators.Optional(), ValidateListRegex()]) + webdriver_js_execute_code = TextAreaField('Execute JavaScript before change detection', render_kw={"rows": "5"}, validators=[validators.Optional()]) + + save_button = SubmitField('Save', render_kw={"class": "pure-button button-small pure-button-primary"}) + + proxy = RadioField('Proxy') + filter_failure_notification_send = BooleanField( + 'Send a notification when the filter can no longer be found on the page', default=False) + + notification_muted = BooleanField('Notifications Muted / Off', default=False) + notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False) + + conditions_match_logic = RadioField(u'Match', choices=[('ALL', 'Match all of the following'),('ANY', 'Match any of the following')], default='ALL') + conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here + + + def extra_tab_content(self): + return None + + def extra_form_content(self): + return None + + def validate(self, **kwargs): + if not super().validate(): + return False + + from changedetectionio.safe_jinja import render as jinja_render + result = True + + # Fail form validation when a body is set for a GET + if self.method.data == 'GET' and self.body.data: + self.body.errors.append('Body must be empty when Request Method is set to GET') + result = False + + # Attempt to validate jinja2 templates in the URL + try: + jinja_render(template_str=self.url.data) + except ModuleNotFoundError as e: + # incase jinja2_time or others is missing + logger.error(e) + self.url.errors.append(f'Invalid template syntax configuration: {e}') + result = False + except Exception as e: + logger.error(e) + self.url.errors.append(f'Invalid template syntax: {e}') + result = False + + # Attempt to validate jinja2 templates in the body + if self.body.data and self.body.data.strip(): + try: + jinja_render(template_str=self.body.data) + except ModuleNotFoundError as e: + # incase jinja2_time or others is missing + logger.error(e) + self.body.errors.append(f'Invalid template syntax configuration: {e}') + result = False + except Exception as e: + logger.error(e) + self.body.errors.append(f'Invalid template syntax: {e}') + result = False + + # Attempt to validate jinja2 templates in the headers + if len(self.headers.data) > 0: + try: + for header, value in self.headers.data.items(): + jinja_render(template_str=value) + except ModuleNotFoundError as e: + # incase jinja2_time or others is missing + logger.error(e) + self.headers.errors.append(f'Invalid template syntax configuration: {e}') + result = False + except Exception as e: + logger.error(e) + self.headers.errors.append(f'Invalid template syntax in "{header}" header: {e}') + result = False + + return result + + def __init__( + self, + formdata=None, + obj=None, + prefix="", + data=None, + meta=None, + **kwargs, + ): + super().__init__(formdata, obj, prefix, data, meta, **kwargs) + if kwargs and kwargs.get('default_system_settings'): + default_tz = kwargs.get('default_system_settings').get('application', {}).get('timezone') + if default_tz: + self.time_schedule_limit.form.timezone.render_kw['placeholder'] = default_tz + + + +class SingleExtraProxy(Form): + + # maybe better to set some <script>var.. + proxy_name = StringField('Name', [validators.Optional()], render_kw={"placeholder": "Name"}) + proxy_url = StringField('Proxy URL', [validators.Optional()], render_kw={"placeholder": "socks5:// or regular proxy http://user:pass@...:3128", "size":50}) + # @todo do the validation here instead + +class SingleExtraBrowser(Form): + browser_name = StringField('Name', [validators.Optional()], render_kw={"placeholder": "Name"}) + browser_connection_url = StringField('Browser connection URL', [validators.Optional()], render_kw={"placeholder": "wss://brightdata... wss://oxylabs etc", "size":50}) + # @todo do the validation here instead + +class DefaultUAInputForm(Form): + html_requests = StringField('Plaintext requests', validators=[validators.Optional()], render_kw={"placeholder": "<default>"}) + if os.getenv("PLAYWRIGHT_DRIVER_URL") or os.getenv("WEBDRIVER_URL"): + html_webdriver = StringField('Chrome requests', validators=[validators.Optional()], render_kw={"placeholder": "<default>"}) + +# datastore.data['settings']['requests'].. +class globalSettingsRequestForm(Form): + time_between_check = FormField(TimeBetweenCheckForm) + time_schedule_limit = FormField(ScheduleLimitForm) + proxy = RadioField('Proxy') + jitter_seconds = IntegerField('Random jitter seconds ± check', + render_kw={"style": "width: 5em;"}, + validators=[validators.NumberRange(min=0, message="Should contain zero or more seconds")]) + + workers = IntegerField('Number of fetch workers', + render_kw={"style": "width: 5em;"}, + validators=[validators.NumberRange(min=1, max=50, + message="Should be between 1 and 50")]) + + extra_proxies = FieldList(FormField(SingleExtraProxy), min_entries=5) + extra_browsers = FieldList(FormField(SingleExtraBrowser), min_entries=5) + + default_ua = FormField(DefaultUAInputForm, label="Default User-Agent overrides") + + def validate_extra_proxies(self, extra_validators=None): + for e in self.data['extra_proxies']: + if e.get('proxy_name') or e.get('proxy_url'): + if not e.get('proxy_name','').strip() or not e.get('proxy_url','').strip(): + self.extra_proxies.errors.append('Both a name, and a Proxy URL is required.') + return False + +class globalSettingsApplicationUIForm(Form): + open_diff_in_new_tab = BooleanField('Open diff page in a new tab', default=True, validators=[validators.Optional()]) + socket_io_enabled = BooleanField('Realtime UI Updates Enabled', default=True, validators=[validators.Optional()]) + +# datastore.data['settings']['application'].. +class globalSettingsApplicationForm(commonSettingsForm): + + api_access_token_enabled = BooleanField('API access token security check enabled', default=True, validators=[validators.Optional()]) + base_url = StringField('Notification base URL override', + validators=[validators.Optional()], + render_kw={"placeholder": os.getenv('BASE_URL', 'Not set')} + ) + empty_pages_are_a_change = BooleanField('Treat empty pages as a change?', default=False) + fetch_backend = RadioField('Fetch Method', default="html_requests", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) + global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()]) + global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)]) + ignore_whitespace = BooleanField('Ignore whitespace') + password = SaltyPasswordField() + pager_size = IntegerField('Pager size', + render_kw={"style": "width: 5em;"}, + validators=[validators.NumberRange(min=0, + message="Should be atleast zero (disabled)")]) + + rss_content_format = SelectField('RSS Content format', choices=RSS_FORMAT_TYPES) + + removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"}) + render_anchor_tag_content = BooleanField('Render anchor tag content', default=False) + shared_diff_access = BooleanField('Allow access to view diff page when password is enabled', default=False, validators=[validators.Optional()]) + rss_hide_muted_watches = BooleanField('Hide muted watches from RSS feed', default=True, + validators=[validators.Optional()]) + filter_failure_notification_threshold_attempts = IntegerField('Number of times the filter can be missing before sending a notification', + render_kw={"style": "width: 5em;"}, + validators=[validators.NumberRange(min=0, + message="Should contain zero or more attempts")]) + ui = FormField(globalSettingsApplicationUIForm) + + +class globalSettingsForm(Form): + # Define these as FormFields/"sub forms", this way it matches the JSON storage + # datastore.data['settings']['application'].. + # datastore.data['settings']['requests'].. + def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs): + super().__init__(formdata, obj, prefix, data, meta, **kwargs) + self.application.notification_body.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) + self.application.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) + self.application.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) + + requests = FormField(globalSettingsRequestForm) + application = FormField(globalSettingsApplicationForm) + save_button = SubmitField('Save', render_kw={"class": "pure-button button-small pure-button-primary"}) + + +class extractDataForm(Form): + extract_regex = StringField('RegEx to extract', validators=[validators.Length(min=1, message="Needs a RegEx")]) + extract_submit_button = SubmitField('Extract as CSV', render_kw={"class": "pure-button pure-button-primary"}) diff --git a/changedetectionio/gc_cleanup.py b/changedetectionio/gc_cleanup.py new file mode 100644 index 0000000000000000000000000000000000000000..2242ff096606da73396ea156c189cf5c3b81b0c4 --- /dev/null +++ b/changedetectionio/gc_cleanup.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 + +import ctypes +import gc +import re +import psutil +import sys +import threading +import importlib +from loguru import logger + +def memory_cleanup(app=None): + """ + Perform comprehensive memory cleanup operations and log memory usage + at each step with nicely formatted numbers. + + Args: + app: Optional Flask app instance for clearing Flask-specific caches + + Returns: + str: Status message + """ + # Get current process + process = psutil.Process() + + # Log initial memory usage with nicely formatted numbers + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"Memory cleanup started - Current memory usage: {current_memory:,.2f} MB") + + # 1. Standard garbage collection - force full collection on all generations + gc.collect(0) # Collect youngest generation + gc.collect(1) # Collect middle generation + gc.collect(2) # Collect oldest generation + + # Run full collection again to ensure maximum cleanup + gc.collect() + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"After full gc.collect() - Memory usage: {current_memory:,.2f} MB") + + + # 3. Call libc's malloc_trim to release memory back to the OS + libc = ctypes.CDLL("libc.so.6") + libc.malloc_trim(0) + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"After malloc_trim(0) - Memory usage: {current_memory:,.2f} MB") + + # 4. Clear Python's regex cache + re.purge() + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"After re.purge() - Memory usage: {current_memory:,.2f} MB") + + # 5. Reset thread-local storage + # Create a new thread local object to encourage cleanup of old ones + threading.local() + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"After threading.local() - Memory usage: {current_memory:,.2f} MB") + + # 6. Clear sys.intern cache if Python version supports it + try: + sys.intern.clear() + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"After sys.intern.clear() - Memory usage: {current_memory:,.2f} MB") + except (AttributeError, TypeError): + logger.debug("sys.intern.clear() not supported in this Python version") + + # 7. Clear XML/lxml caches if available + try: + # Check if lxml.etree is in use + lxml_etree = sys.modules.get('lxml.etree') + if lxml_etree: + # Clear module-level caches + if hasattr(lxml_etree, 'clear_error_log'): + lxml_etree.clear_error_log() + + # Check for _ErrorLog and _RotatingErrorLog objects and clear them + for obj in gc.get_objects(): + if hasattr(obj, '__class__') and hasattr(obj.__class__, '__name__'): + class_name = obj.__class__.__name__ + if class_name in ('_ErrorLog', '_RotatingErrorLog', '_DomainErrorLog') and hasattr(obj, 'clear'): + try: + obj.clear() + except (AttributeError, TypeError): + pass + + # Clear Element objects which can hold references to documents + elif class_name in ('_Element', 'ElementBase') and hasattr(obj, 'clear'): + try: + obj.clear() + except (AttributeError, TypeError): + pass + + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"After lxml.etree cleanup - Memory usage: {current_memory:,.2f} MB") + + # Check if lxml.html is in use + lxml_html = sys.modules.get('lxml.html') + if lxml_html: + # Clear HTML-specific element types + for obj in gc.get_objects(): + if hasattr(obj, '__class__') and hasattr(obj.__class__, '__name__'): + class_name = obj.__class__.__name__ + if class_name in ('HtmlElement', 'FormElement', 'InputElement', + 'SelectElement', 'TextareaElement', 'CheckboxGroup', + 'RadioGroup', 'MultipleSelectOptions', 'FieldsDict') and hasattr(obj, 'clear'): + try: + obj.clear() + except (AttributeError, TypeError): + pass + + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"After lxml.html cleanup - Memory usage: {current_memory:,.2f} MB") + except (ImportError, AttributeError): + logger.debug("lxml cleanup not applicable") + + # 8. Clear JSON parser caches if applicable + try: + # Check if json module is being used and try to clear its cache + json_module = sys.modules.get('json') + if json_module and hasattr(json_module, '_default_encoder'): + json_module._default_encoder.markers.clear() + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"After JSON parser cleanup - Memory usage: {current_memory:,.2f} MB") + except (AttributeError, KeyError): + logger.debug("JSON cleanup not applicable") + + # 9. Force Python's memory allocator to release unused memory + try: + if hasattr(sys, 'pypy_version_info'): + # PyPy has different memory management + gc.collect() + else: + # CPython - try to release unused memory + ctypes.pythonapi.PyGC_Collect() + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"After PyGC_Collect - Memory usage: {current_memory:,.2f} MB") + except (AttributeError, TypeError): + logger.debug("PyGC_Collect not supported") + + # 10. Clear Flask-specific caches if applicable + if app: + try: + # Clear Flask caches if they exist + for key in list(app.config.get('_cache', {}).keys()): + app.config['_cache'].pop(key, None) + + # Clear Jinja2 template cache if available + if hasattr(app, 'jinja_env') and hasattr(app.jinja_env, 'cache'): + app.jinja_env.cache.clear() + + current_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"After Flask cache clear - Memory usage: {current_memory:,.2f} MB") + except (AttributeError, KeyError): + logger.debug("No Flask cache to clear") + + # Final garbage collection pass + gc.collect() + libc.malloc_trim(0) + + # Log final memory usage + final_memory = process.memory_info().rss / 1024 / 1024 + logger.info(f"Memory cleanup completed - Final memory usage: {final_memory:,.2f} MB") + return "cleaned" \ No newline at end of file diff --git a/changedetectionio/html_tools.py b/changedetectionio/html_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..42b7f8c995e6b44eb8861ba086b41e017bb70c80 --- /dev/null +++ b/changedetectionio/html_tools.py @@ -0,0 +1,512 @@ +from loguru import logger +from lxml import etree +from typing import List +import json +import re + +# HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis +TEXT_FILTER_LIST_LINE_SUFFIX = "<br>" +TRANSLATE_WHITESPACE_TABLE = str.maketrans('', '', '\r\n\t ') +PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$' + +# 'price' , 'lowPrice', 'highPrice' are usually under here +# All of those may or may not appear on different websites - I didnt find a way todo case-insensitive searching here +LD_JSON_PRODUCT_OFFER_SELECTORS = ["json:$..offers", "json:$..Offers"] + +class JSONNotFound(ValueError): + def __init__(self, msg): + ValueError.__init__(self, msg) + + +# Doesn't look like python supports forward slash auto enclosure in re.findall +# So convert it to inline flag "(?i)foobar" type configuration +def perl_style_slash_enclosed_regex_to_options(regex): + + res = re.search(PERL_STYLE_REGEX, regex, re.IGNORECASE) + + if res: + flags = res.group(2) if res.group(2) else 'i' + regex = f"(?{flags}){res.group(1)}" + else: + # Fall back to just ignorecase as an option + regex = f"(?i){regex}" + + return regex + +# Given a CSS Rule, and a blob of HTML, return the blob of HTML that matches +def include_filters(include_filters, html_content, append_pretty_line_formatting=False): + from bs4 import BeautifulSoup + soup = BeautifulSoup(html_content, "html.parser") + html_block = "" + r = soup.select(include_filters, separator="") + + for element in r: + # When there's more than 1 match, then add the suffix to separate each line + # And where the matched result doesn't include something that will cause Inscriptis to add a newline + # (This way each 'match' reliably has a new-line in the diff) + # Divs are converted to 4 whitespaces by inscriptis + if append_pretty_line_formatting and len(html_block) and not element.name in (['br', 'hr', 'div', 'p']): + html_block += TEXT_FILTER_LIST_LINE_SUFFIX + + html_block += str(element) + + return html_block + +def subtractive_css_selector(css_selector, html_content): + from bs4 import BeautifulSoup + soup = BeautifulSoup(html_content, "html.parser") + + # So that the elements dont shift their index, build a list of elements here which will be pointers to their place in the DOM + elements_to_remove = soup.select(css_selector) + + # Then, remove them in a separate loop + for item in elements_to_remove: + item.decompose() + + return str(soup) + +def subtractive_xpath_selector(selectors: List[str], html_content: str) -> str: + # Parse the HTML content using lxml + html_tree = etree.HTML(html_content) + + # First, collect all elements to remove + elements_to_remove = [] + + # Iterate over the list of XPath selectors + for selector in selectors: + # Collect elements for each selector + elements_to_remove.extend(html_tree.xpath(selector)) + + # Then, remove them in a separate loop + for element in elements_to_remove: + if element.getparent() is not None: # Ensure the element has a parent before removing + element.getparent().remove(element) + + # Convert the modified HTML tree back to a string + modified_html = etree.tostring(html_tree, method="html").decode("utf-8") + return modified_html + + +def element_removal(selectors: List[str], html_content): + """Removes elements that match a list of CSS or XPath selectors.""" + modified_html = html_content + css_selectors = [] + xpath_selectors = [] + + for selector in selectors: + if selector.startswith(('xpath:', 'xpath1:', '//')): + # Handle XPath selectors separately + xpath_selector = selector.removeprefix('xpath:').removeprefix('xpath1:') + xpath_selectors.append(xpath_selector) + else: + # Collect CSS selectors as one "hit", see comment in subtractive_css_selector + css_selectors.append(selector.strip().strip(",")) + + if xpath_selectors: + modified_html = subtractive_xpath_selector(xpath_selectors, modified_html) + + if css_selectors: + # Remove duplicates, then combine all CSS selectors into one string, separated by commas + # This stops the elements index shifting + unique_selectors = list(set(css_selectors)) # Ensure uniqueness + combined_css_selector = " , ".join(unique_selectors) + modified_html = subtractive_css_selector(combined_css_selector, modified_html) + + + return modified_html + +def elementpath_tostring(obj): + """ + change elementpath.select results to string type + # The MIT License (MIT), Copyright (c), 2018-2021, SISSA (Scuola Internazionale Superiore di Studi Avanzati) + # https://github.com/sissaschool/elementpath/blob/dfcc2fd3d6011b16e02bf30459a7924f547b47d0/elementpath/xpath_tokens.py#L1038 + """ + + import elementpath + from decimal import Decimal + import math + + if obj is None: + return '' + # https://elementpath.readthedocs.io/en/latest/xpath_api.html#elementpath.select + elif isinstance(obj, elementpath.XPathNode): + return obj.string_value + elif isinstance(obj, bool): + return 'true' if obj else 'false' + elif isinstance(obj, Decimal): + value = format(obj, 'f') + if '.' in value: + return value.rstrip('0').rstrip('.') + return value + + elif isinstance(obj, float): + if math.isnan(obj): + return 'NaN' + elif math.isinf(obj): + return str(obj).upper() + + value = str(obj) + if '.' in value: + value = value.rstrip('0').rstrip('.') + if '+' in value: + value = value.replace('+', '') + if 'e' in value: + return value.upper() + return value + + return str(obj) + +# Return str Utf-8 of matched rules +def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False, is_rss=False): + from lxml import etree, html + import elementpath + # xpath 2.0-3.1 + from elementpath.xpath3 import XPath3Parser + + parser = etree.HTMLParser() + if is_rss: + # So that we can keep CDATA for cdata_in_document_to_text() to process + parser = etree.XMLParser(strip_cdata=False) + + tree = html.fromstring(bytes(html_content, encoding='utf-8'), parser=parser) + html_block = "" + + r = elementpath.select(tree, xpath_filter.strip(), namespaces={'re': 'http://exslt.org/regular-expressions'}, parser=XPath3Parser) + #@note: //title/text() wont work where <title>CDATA.. + + if type(r) != list: + r = [r] + + for element in r: + # When there's more than 1 match, then add the suffix to separate each line + # And where the matched result doesn't include something that will cause Inscriptis to add a newline + # (This way each 'match' reliably has a new-line in the diff) + # Divs are converted to 4 whitespaces by inscriptis + if append_pretty_line_formatting and len(html_block) and (not hasattr( element, 'tag' ) or not element.tag in (['br', 'hr', 'div', 'p'])): + html_block += TEXT_FILTER_LIST_LINE_SUFFIX + + if type(element) == str: + html_block += element + elif issubclass(type(element), etree._Element) or issubclass(type(element), etree._ElementTree): + html_block += etree.tostring(element, pretty_print=True).decode('utf-8') + else: + html_block += elementpath_tostring(element) + + return html_block + +# Return str Utf-8 of matched rules +# 'xpath1:' +def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=False, is_rss=False): + from lxml import etree, html + + parser = None + if is_rss: + # So that we can keep CDATA for cdata_in_document_to_text() to process + parser = etree.XMLParser(strip_cdata=False) + + tree = html.fromstring(bytes(html_content, encoding='utf-8'), parser=parser) + html_block = "" + + r = tree.xpath(xpath_filter.strip(), namespaces={'re': 'http://exslt.org/regular-expressions'}) + #@note: //title/text() wont work where <title>CDATA.. + + for element in r: + # When there's more than 1 match, then add the suffix to separate each line + # And where the matched result doesn't include something that will cause Inscriptis to add a newline + # (This way each 'match' reliably has a new-line in the diff) + # Divs are converted to 4 whitespaces by inscriptis + if append_pretty_line_formatting and len(html_block) and (not hasattr(element, 'tag') or not element.tag in (['br', 'hr', 'div', 'p'])): + html_block += TEXT_FILTER_LIST_LINE_SUFFIX + + # Some kind of text, UTF-8 or other + if isinstance(element, (str, bytes)): + html_block += element + else: + # Return the HTML which will get parsed as text + html_block += etree.tostring(element, pretty_print=True).decode('utf-8') + + return html_block + +# Extract/find element +def extract_element(find='title', html_content=''): + from bs4 import BeautifulSoup + + #Re #106, be sure to handle when its not found + element_text = None + + soup = BeautifulSoup(html_content, 'html.parser') + result = soup.find(find) + if result and result.string: + element_text = result.string.strip() + + return element_text + +# +def _parse_json(json_data, json_filter): + from jsonpath_ng.ext import parse + + if json_filter.startswith("json:"): + jsonpath_expression = parse(json_filter.replace('json:', '')) + match = jsonpath_expression.find(json_data) + return _get_stripped_text_from_json_match(match) + + if json_filter.startswith("jq:") or json_filter.startswith("jqraw:"): + + try: + import jq + except ModuleNotFoundError: + # `jq` requires full compilation in windows and so isn't generally available + raise Exception("jq not support not found") + + if json_filter.startswith("jq:"): + jq_expression = jq.compile(json_filter.removeprefix("jq:")) + match = jq_expression.input(json_data).all() + return _get_stripped_text_from_json_match(match) + + if json_filter.startswith("jqraw:"): + jq_expression = jq.compile(json_filter.removeprefix("jqraw:")) + match = jq_expression.input(json_data).all() + return '\n'.join(str(item) for item in match) + +def _get_stripped_text_from_json_match(match): + s = [] + # More than one result, we will return it as a JSON list. + if len(match) > 1: + for i in match: + s.append(i.value if hasattr(i, 'value') else i) + + # Single value, use just the value, as it could be later used in a token in notifications. + if len(match) == 1: + s = match[0].value if hasattr(match[0], 'value') else match[0] + + # Re #257 - Better handling where it does not exist, in the case the original 's' value was False.. + if not match: + # Re 265 - Just return an empty string when filter not found + return '' + + # Ticket #462 - allow the original encoding through, usually it's UTF-8 or similar + stripped_text_from_html = json.dumps(s, indent=4, ensure_ascii=False) + + return stripped_text_from_html + +# content - json +# json_filter - ie json:$..price +# ensure_is_ldjson_info_type - str "product", optional, "@type == product" (I dont know how to do that as a json selector) +def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None): + from bs4 import BeautifulSoup + + stripped_text_from_html = False +# https://github.com/dgtlmoon/changedetection.io/pull/2041#issuecomment-1848397161w + # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded within HTML tags + try: + # .lstrip("\ufeff") strings ByteOrderMark from UTF8 and still lets the UTF work + stripped_text_from_html = _parse_json(json.loads(content.lstrip("\ufeff") ), json_filter) + except json.JSONDecodeError as e: + logger.warning(str(e)) + + # Foreach <script json></script> blob.. just return the first that matches json_filter + # As a last resort, try to parse the whole <body> + 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 = '<!\[CDATA\[(\s*(?:.(?<!\]\]>)\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'<title([\s>])', r'<h1\1', html_content) + html_content = re.sub(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 <p>something</p> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<browserconfig> + <msapplication> + <tile> + <square150x150logo src="favicons/mstile-150x150.png"/> + <TileColor>#da532c</TileColor> + </tile> + </msapplication> +</browserconfig> 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 @@ +<?xml version="1.0" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" + "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" + width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000" + preserveAspectRatio="xMidYMid meet"> +<metadata> +Created by potrace 1.14, written by Peter Selinger 2001-2017 +</metadata> +<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)" +fill="#000000" stroke="none"> +<path d="M0 1280 l0 -1280 1280 0 1280 0 0 1280 0 1280 -1280 0 -1280 0 0 +-1280z m1555 936 c387 -112 675 -426 741 -810 24 -138 15 -352 -20 -470 -106 +-353 -360 -606 -713 -712 -75 -22 -113 -27 -253 -31 -144 -5 -176 -2 -252 16 +-316 75 -564 271 -707 557 -67 136 -92 237 -98 401 -7 164 5 253 47 378 106 +315 349 556 665 659 114 37 180 45 350 41 125 -2 165 -7 240 -29z"/> +<path d="M1091 2165 c-364 -82 -629 -328 -738 -682 -24 -80 -27 -103 -27 -258 +-1 -146 2 -182 21 -251 74 -271 259 -497 508 -621 477 -238 1061 -35 1294 450 +61 126 83 220 88 379 7 194 -15 307 -93 461 -126 251 -340 428 -614 507 -99 +29 -343 37 -439 15z m829 -473 c55 -54 100 -106 100 -116 0 -21 -184 -213 +-212 -222 -24 -7 -48 12 -48 38 0 11 26 47 58 80 l57 60 -151 -3 c-145 -4 +-152 -5 -190 -31 -22 -15 -78 -73 -124 -128 l-85 -99 -32 31 -32 31 30 38 c17 +22 70 79 117 128 66 67 97 92 127 100 22 6 106 11 188 11 81 0 147 3 147 8 0 +4 -25 31 -55 61 -55 55 -65 77 -43 99 25 25 50 10 148 -86z m-1002 -101 c46 +-24 141 -121 312 -321 203 -236 290 -330 322 -346 22 -11 60 -14 169 -12 l141 +3 -51 58 c-28 32 -51 64 -51 71 0 18 21 36 43 36 24 0 217 -193 217 -217 0 +-19 -185 -210 -212 -219 -24 -7 -48 12 -48 38 0 10 23 43 50 72 l50 53 -52 7 +c-29 3 -93 6 -142 6 -104 0 -152 12 -200 52 -19 15 -135 144 -258 286 -274 +316 -305 347 -354 361 -22 6 -94 11 -161 11 -67 0 -128 3 -137 6 -22 9 -21 61 +2 67 9 3 86 5 170 6 133 1 158 -2 190 -18z m227 -468 c23 -34 17 -43 -103 +-172 -119 -128 -131 -133 -343 -129 l-154 3 0 35 c0 34 1 35 50 42 28 3 96 7 +153 7 64 1 115 6 136 15 20 8 71 56 127 120 52 58 99 106 105 106 7 0 20 -12 +29 -27z"/> +</g> +</svg> 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 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg width="15" height="16.363636" viewBox="0 0 15 16.363636" xmlns="http://www.w3.org/2000/svg" > + <path d="m 14.318182,11.762045 v 1.1925 H 5.4102273 L 11.849318,7.1140909 C 12.234545,9.1561364 12.54,11.181818 14.318182,11.762045 Z m -6.7984093,4.601591 c 1.0759091,0 2.0256823,-0.955909 2.0256823,-2.045454 H 5.4545455 c 0,1.089545 0.9879545,2.045454 2.0652272,2.045454 z M 15,2.8622727 0.9177273,15.636136 0,14.627045 l 1.8443182,-1.6725 h -1.1625 v -1.1925 C 4.0070455,10.677273 2.1784091,4.5388636 5.3611364,2.6897727 5.8009091,2.4347727 6.0709091,1.9609091 6.0702273,1.4488636 v -0.00205 C 6.0702273,0.64772727 6.7104545,0 7.5,0 8.2895455,0 8.9297727,0.64772727 8.9297727,1.4468182 v 0.00205 C 8.9290909,1.9602319 9.199773,2.4354591 9.638864,2.6897773 10.364318,3.111141 10.827273,3.7568228 11.1525,4.5129591 L 14.085682,1.8531818 Z M 6.8181818,1.3636364 C 6.8181818,1.74 7.1236364,2.0454545 7.5,2.0454545 7.8763636,2.0454545 8.1818182,1.74 8.1818182,1.3636364 8.1818182,0.98795455 7.8763636,0.68181818 7.5,0.68181818 c -0.3763636,0 -0.6818182,0.30613637 -0.6818182,0.68181822 z" id="path2" style="fill:#f8321b;stroke-width:0.681818;fill-opacity:1"/> +</svg> 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 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + height="59.553207" + viewBox="-0.36 95.21 25.082135 59.553208" + width="249.99138" + version="1.1" + id="svg12" + sodipodi:docname="brightdata.svg" + inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + > + <defs + id="defs16" /> + <sodipodi:namedview + id="namedview14" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + showgrid="false" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" + inkscape:zoom="0.9464" + inkscape:cx="22.189349" + inkscape:cy="-90.870668" + inkscape:window-width="1920" + inkscape:window-height="1051" + inkscape:window-x="1920" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="svg12" /> + <path + d="m -34.416031,129.28 c -3.97,-2.43 -5.1,-6.09 -4.32,-10.35 0.81,-4.4 3.95,-6.75 8.04,-7.75 4.23,-1.04 8.44,-0.86 12.3,1.5 0.63,0.39 0.93,0.03 1.31,-0.29 1.5,-1.26 3.27,-1.72 5.189999,-1.83 0.79,-0.05 1.04,0.24 1.01,1.01 -0.05,1.31 -0.04,2.63 0,3.95 0.02,0.65 -0.19,0.93 -0.87,0.89 -0.889999,-0.04 -1.789999,0.03 -2.669999,-0.02 -0.82,-0.04 -1.08,0.1 -0.88,1.04 0.83,3.9 -0.06,7.37 -3.1,10.06 -2.76,2.44 -6.13,3.15 -9.72,3.04 -0.51,-0.02 -1.03,-0.02 -1.52,-0.13 -1.22,-0.25 -1.96,0.14 -2.19,1.41 -0.28,1.54 0.16,2.62 1.37,3.07 0.84,0.31 1.74,0.35 2.63,0.39 2.97,0.13 5.95,-0.18 8.91,0.21 2.93,0.39 5.69,1.16 6.85,4.25 1.269999,3.38 0.809999,6.62 -1.48,9.47 -2.73,3.39 -6.52,4.78 -10.66,5.33 -3.53,0.48 -7.04,0.27 -10.39,-1.11 -3.89,-1.6 -5.75,-4.95 -4.84,-8.72 0.51,-2.11 1.85,-3.58 3.69,-4.65 0.38,-0.22 0.93,-0.32 0.28,-0.96 -2.91,-2.83 -2.85,-6.16 0.1,-8.95 0.28,-0.26 0.6,-0.53 0.96,-0.86 z m 8.07,21.5 c 0.95,0.04 1.87,-0.13 2.78,-0.33 1.89,-0.42 3.51,-1.3 4.49,-3.06 1.82,-3.25 0.24,-6.2 -3.37,-6.58 -2.88,-0.3 -5.76,0.24 -8.63,-0.13 -0.53,-0.07 -0.75,0.34 -0.95,0.71 -1.16,2.24 -1.08,4.53 0,6.73 1.15,2.34 3.46,2.48 5.68,2.66 z m -5,-30.61 c -0.03,1.67 0.08,3.19 0.74,4.61 0.76,1.62 2.17,2.42 4.03,2.31 1.62,-0.1 2.9,-1.12 3.36,-2.84 0.66,-2.46 0.69,-4.95 0.01,-7.42 -0.49,-1.76 -1.7,-2.64 -3.56,-2.7 -2.08,-0.07 -3.37,0.7 -4.04,2.42 -0.47,1.21 -0.6,2.47 -0.54,3.62 z m 32.9399993,6.56 c 0,2.59 0.05,5.18 -0.02,7.77 -0.03,1.03 0.31,1.46 1.32,1.52 0.65,0.04 1.61,-0.09 1.82,0.57 0.26,0.81 0.11,1.76 0.06,2.65 -0.03,0.48 -0.81,0.39 -0.81,0.39 l -11.47,0.01 c 0,0 -0.95,-0.21 -0.88,-0.88 0.03,-0.29 0.04,-0.6 0,-0.89 -0.19,-1.24 0.21,-1.92 1.58,-1.9 0.99,0.01 1.28,-0.52 1.28,-1.53 -0.05,-8.75 -0.05,-17.49 0,-26.24 0.01,-1.15 -0.36,-1.62 -1.44,-1.67 -0.17,-0.01 -0.34,-0.04 -0.5,-0.07 -1.43,-0.22 -2.12,-1.57 -1.53,-2.91 0.15,-0.35 0.43,-0.36 0.72,-0.4 2.94,-0.41 5.88,-0.81 8.82000002,-1.23 0.81999998,-0.12 0.99999998,0.27 0.98999998,1.01 -0.02,3.35 0,6.71 0.02,10.06 0,0.35 -0.23,0.84 0.18,1.03 0.38,0.17 0.69,-0.25 0.99,-0.45 2.56,-1.74 5.33,-2.73 8.4900007,-2.56 3.51005,0.19 5.65005,1.95 6.35005,5.46 0.42,2.09 0.52,4.21 0.51,6.33 -0.02,3.86 0.05,7.73 -0.04,11.59 -0.02,1.12 0.37,1.5 1.39,1.6 0.61,0.05 1.55,-0.13 1.74,0.47 0.26,0.85 0.12,1.84 0.1,2.77 -0.01,0.41 -0.69,0.37 -0.69,0.37 l -11.4700504,0.01 c 0,0 -0.81,-0.29 -0.8,-0.85 0.01,-0.38 0.04,-0.77 -0.01,-1.15 -0.13,-1.01 0.32,-1.52 1.31,-1.56 1.0600004,-0.05 1.3800004,-0.55 1.3500004,-1.63 -0.14,-4.84 0.16,-9.68 -0.18,-14.51 -0.26,-3.66 -2.1100004,-4.95 -5.6700007,-3.99 -0.25,0.07 -0.49,0.15 -0.73,0.22 -2.57,0.8 -2.79,1.09 -2.79,3.71 0.01,2.3 0.01,4.59 0.01,6.88 z M -109.26603,122.56 c 0,-4.75 -0.02,-9.51 0.02,-14.26 0.01,-0.92 -0.17,-1.47 -1.19,-1.45 -0.16,0 -0.33,-0.07 -0.5,-0.1 -1.56,-0.27 -2.24,-1.47 -1.69,-2.92 0.14,-0.37 0.41,-0.38 0.7,-0.42 2.98,-0.41 5.97,-0.81 8.94,-1.24 0.85,-0.12 0.88,0.33 0.88,0.96 -0.01,3.01 -0.01,6.03 0,9.04 0,0.4 -0.18,0.96 0.27,1.16 0.36,0.16 0.66,-0.3 0.96,-0.52 4.729999,-3.51 12.459999,-2.61 14.889999,4.48 1.89,5.51 1.91,11.06 -0.96,16.28 -2.37,4.31 -6.19,6.49 -11.15,6.59 -3.379999,0.07 -6.679999,-0.3 -9.909999,-1.37 -0.93,-0.31 -1.3,-0.78 -1.28,-1.83 0.05,-4.81 0.02,-9.6 0.02,-14.4 z m 7.15,3.89 c 0,2.76 0.02,5.52 -0.01,8.28 -0.01,0.76 0.18,1.29 0.91,1.64 1.899999,0.9 4.299999,0.5 5.759999,-1.01 0.97,-1 1.56,-2.21 1.96,-3.52 1.03,-3.36 0.97,-6.78 0.61,-10.22 a 9.991,9.991 0 0 0 -0.93,-3.29 c -1.47,-3.06 -4.67,-3.85 -7.439999,-1.86 -0.6,0.43 -0.88,0.93 -0.87,1.7 0.04,2.76 0.01,5.52 0.01,8.28 z" + fill="#4280f6" + id="path2" /> + <path + d="m 68.644019,137.2 c -1.62,1.46 -3.41,2.56 -5.62,2.96 -4.4,0.8 -8.7,-1.39 -10.49,-5.49 -2.31,-5.31 -2.3,-10.67 -0.1,-15.98 2.31,-5.58 8.29,-8.65 14.24,-7.46 1.71,0.34 1.9,0.18 1.9,-1.55 0,-0.68 -0.05,-1.36 0.01,-2.04 0.09,-1.02 -0.25,-1.54 -1.34,-1.43 -0.64,0.06 -1.26,-0.1 -1.88,-0.21 -1.32,-0.24 -1.6,-0.62 -1.37,-1.97 0.07,-0.41 0.25,-0.57 0.65,-0.62 2.63,-0.33 5.27,-0.66 7.9,-1.02 1.04,-0.14 1.17,0.37 1.17,1.25 -0.02,10.23 -0.02,20.45 -0.01,30.68 v 1.02 c 0.02,0.99 0.35,1.6 1.52,1.47 0.52,-0.06 1.35,-0.27 1.25,0.73 -0.08,0.8 0.58,1.93 -0.94,2.18 -1.29,0.22 -2.51,0.69 -3.86,0.65 -2.04,-0.06 -2.3,-0.23 -2.76,-2.19 -0.09,-0.3 0.06,-0.67 -0.27,-0.98 z m -0.07,-12.46 c 0,-2.8 -0.04,-5.6 0.02,-8.39 0.02,-0.9 -0.28,-1.47 -1.05,-1.81 -3.18,-1.4 -7.54,-0.8 -9.3,2.87 -0.83,1.74 -1.31,3.54 -1.49,5.46 -0.28,2.93 -0.38,5.83 0.61,8.65 0.73,2.09 1.81,3.9 4.11,4.67 2.49,0.83 4.55,-0.04 6.5,-1.48 0.54,-0.4 0.62,-0.95 0.61,-1.57 -0.02,-2.8 -0.01,-5.6 -0.01,-8.4 z m 28.79,2.53 c 0,3.24 0.04,5.83 -0.02,8.41 -0.02,1 0.19,1.49 1.309998,1.41 0.55,-0.04 1.460003,-0.46 1.520003,0.73 0.05,1.02 0.1,1.89 -1.330003,2.08 -1.289998,0.17 -2.559998,0.51 -3.889998,0.48 -1.88,-0.05 -2.15,-0.26 -2.42,-2.15 -0.04,-0.27 0.14,-0.65 -0.22,-0.79 -0.34,-0.13 -0.5,0.24 -0.72,0.42 -3.61,3 -8.15,3.4 -11.64,1.08 -1.61,-1.07 -2.49,-2.63 -2.67,-4.43 -0.51,-5.13 0.77,-7.91 6.3,-10.22 2.44,-1.02 5.07,-1.27 7.68,-1.49 0.77,-0.07 1.03,-0.28 1.02,-1.05 -0.03,-1.48 -0.05,-2.94 -0.64,-4.36 -0.59,-1.42 -1.67,-1.92 -3.08,-2.03 -3.04,-0.24 -5.88,0.5 -8.63,1.71 -0.51,0.23 -1.19,0.75 -1.48,-0.13 -0.26,-0.77 -1.35,-1.61 0.05,-2.47 3.27,-2 6.7,-3.44 10.61,-3.42 1.44,0.01 2.88,0.27 4.21,0.81 2.67,1.08 3.44,3.4 3.8,5.99 0.46,3.37 0.1,6.73 0.24,9.42 z m -5.09,2.9 c 0,-1.23 -0.01,-2.46 0,-3.69 0,-0.52 -0.06,-0.98 -0.75,-0.84 -1.45,0.3 -2.93,0.28 -4.37,0.69 -3.71,1.04 -5.46,4.48 -3.97,8.03 0.51,1.22 1.48,1.98 2.79,2.16 2.01,0.28 3.86,-0.29 5.6,-1.28 0.54,-0.31 0.73,-0.76 0.72,-1.37 -0.05,-1.23 -0.02,-2.47 -0.02,-3.7 z m 43.060001,-2.89 c 0,2.72 0.01,5.43 -0.01,8.15 0,0.66 0.02,1.21 0.91,1.12 0.54,-0.06 0.99,0.12 0.86,0.75 -0.15,0.71 0.56,1.7 -0.58,2.09 -1.55,0.52 -3.16,0.59 -4.77,0.4 -0.99,-0.12 -1.12,-1.01 -1.18,-1.73 -0.08,-1.15 -0.16,-1.45 -1.24,-0.54 -3.41,2.87 -8.05,3.17 -11.43,0.88 -1.75,-1.18 -2.49,-2.91 -2.7,-4.94 -0.64,-6.24 3.16,-8.74 7.83,-10.17 2.04,-0.62 4.14,-0.8 6.24,-0.99 0.81,-0.07 1,-0.36 0.98,-1.09 -0.04,-1.31 0.04,-2.62 -0.42,-3.89 -0.57,-1.57 -1.53,-2.34 -3.18,-2.45 -3.03,-0.21 -5.88,0.46 -8.64,1.66 -0.6,0.26 -1.25,0.81 -1.68,-0.2 -0.34,-0.8 -1.08,-1.61 0.16,-2.36 4.12,-2.5 8.44,-4.16 13.36,-3.07 3.21,0.71 4.89,2.91 5.26,6.34 0.18,1.69 0.22,3.37 0.22,5.07 0.01,1.66 0.01,3.32 0.01,4.97 z m -5.09,2.54 c 0,-1.27 -0.03,-2.54 0.01,-3.81 0.02,-0.74 -0.27,-1.02 -0.98,-0.92 -1.21,0.17 -2.43,0.28 -3.62,0.55 -3.72,0.83 -5.47,3.48 -4.82,7.21 0.29,1.66 1.57,2.94 3.21,3.16 2.02,0.27 3.85,-0.34 5.57,-1.34 0.49,-0.29 0.64,-0.73 0.63,-1.29 -0.02,-1.18 0,-2.37 0,-3.56 z" + fill="#c8dbfb" + id="path4" /> + <path + d="m 26.314019,125.77 c 0,-2.89 -0.05,-5.77 0.02,-8.66 0.03,-1.04 -0.33,-1.39 -1.31,-1.24 a 0.7,0.7 0 0 1 -0.25,0 c -0.57,-0.18 -1.44,0.48 -1.68,-0.58 -0.35,-1.48 -0.02,-2.3 1.21,-2.7 1.3,-0.43 2.16,-1.26 2.76,-2.46 0.78,-1.56 1.44,-3.17 1.91,-4.84 0.18,-0.63 0.47,-0.86 1.15,-0.88 3.28,-0.09 3.27,-0.11 3.32,3.17 0.01,1.06 0.09,2.12 0.09,3.18 -0.01,0.67 0.27,0.89 0.91,0.88 1.61,-0.02 3.23,0.03 4.84,-0.02 0.77,-0.02 1.01,0.23 1.03,1.01 0.08,3.27 0.1,3.27 -3.09,3.27 -0.93,0 -1.87,0.03 -2.8,-0.01 -0.67,-0.02 -0.89,0.26 -0.88,0.91 0.04,5.43 0.04,10.86 0.12,16.29 0.02,1.7 0.75,2.26 2.46,2.1 1.1,-0.1 2.19,-0.26 3.23,-0.65 0.59,-0.22 0.89,-0.09 1.14,0.53 0.93,2.29 0.92,2.37 -1.32,3.52 -2.54,1.3 -5.22,1.99 -8.1,1.79 -2.27,-0.16 -3.68,-1.27 -4.35,-3.45 -0.3,-0.98 -0.41,-1.99 -0.41,-3.01 z m -97.67005,-8.99 c 0.57,-0.84 1.11,-1.74 1.76,-2.55 1.68,-2.09 3.68,-3.62 6.54,-3.66 1.08,-0.01 1.63,0.28 1.57,1.52 -0.1,2.08 -0.05,4.16 -0.02,6.24 0.01,0.74 -0.17,0.96 -0.96,0.76 -2.36,-0.59 -4.71,-0.42 -7.03,0.28 -0.8,0.24 -1.16,0.62 -1.15,1.52 0.05,4.5 0.04,9 0,13.5 -0.01,0.89 0.29,1.16 1.15,1.2 1.23,0.06 2.44,0.32 3.67,0.39 0.75,0.05 0.91,0.38 0.89,1.04 -0.06,2.86 0.29,2.28 -2.25,2.3 -4.2,0.04 -8.41,-0.02 -12.61,0.03 -0.91,0.01 -1.39,-0.18 -1.22,-1.18 0.02,-0.12 0,-0.25 0,-0.38 0.02,-2.1 -0.24,-1.88 1.77,-2.04 1.33,-0.11 1.6,-0.67 1.58,-1.9 -0.07,-5.35 -0.04,-10.7 -0.02,-16.05 0,-0.78 -0.17,-1.2 -1,-1.46 -2.21,-0.68 -2.7,-1.69 -2.22,-3.99 0.11,-0.52 0.45,-0.56 0.82,-0.62 2.22,-0.34 4.44,-0.7 6.67,-0.99 0.99,-0.13 1.82,0.7 1.84,1.76 0.03,1.4 0.03,2.8 0.04,4.2 -0.01,0.02 0.06,0.04 0.18,0.08 z m 25.24,6.59 c 0,3.69 0.04,7.38 -0.03,11.07 -0.02,1.04 0.31,1.48 1.32,1.49 0.29,0 0.59,0.12 0.88,0.13 0.93,0.01 1.18,0.47 1.16,1.37 -0.05,2.19 0,2.19 -2.24,2.19 -3.48,0 -6.96,-0.04 -10.44,0.03 -1.09,0.02 -1.47,-0.33 -1.3,-1.36 0.02,-0.12 0.02,-0.26 0,-0.38 -0.28,-1.39 0.39,-1.96 1.7,-1.9 1.36,0.06 1.76,-0.51 1.74,-1.88 -0.09,-5.17 -0.08,-10.35 0,-15.53 0.02,-1.22 -0.32,-1.87 -1.52,-2.17 -0.57,-0.14 -1.47,-0.11 -1.57,-0.85 -0.15,-1.04 -0.05,-2.11 0.01,-3.17 0.02,-0.34 0.44,-0.35 0.73,-0.39 2.81,-0.39 5.63,-0.77 8.44,-1.18 0.92,-0.14 1.15,0.2 1.14,1.09 -0.04,3.8 -0.02,7.62 -0.02,11.44 z" + fill="#4280f6" + id="path6" /> + <path + d="m 101.44402,125.64 c 0,-3.18 -0.03,-6.37 0.02,-9.55 0.02,-0.94 -0.26,-1.36 -1.22,-1.22 -0.21,0.03 -0.430003,0.04 -0.630003,0 -0.51,-0.12 -1.35,0.39 -1.44,-0.55 -0.08,-0.85 -0.429998,-1.87 0.93,-2.24 2.080003,-0.57 2.720003,-2.39 3.350003,-4.17 0.31,-0.88 0.62,-1.76 0.87,-2.66 0.18,-0.64 0.52,-0.85 1.19,-0.84 2.46,0.05 2,-0.15 2.04,2.04 0.02,1.1 0.08,2.21 -0.02,3.31 -0.11,1.16 0.46,1.52 1.46,1.53 1.78,0.01 3.57,0.04 5.35,-0.01 0.82,-0.02 1.12,0.23 1.11,1.08 -0.05,2.86 0.19,2.49 -2.42,2.51 -1.53,0.01 -3.06,0.02 -4.59,-0.01 -0.65,-0.01 -0.9,0.22 -0.9,0.89 0.02,5.52 0,11.04 0.03,16.56 0,0.67 0.14,1.34 0.25,2.01 0.17,1.04 1.17,1.62 2.59,1.42 1.29,-0.19 2.57,-0.49 3.86,-0.69 0.43,-0.07 1.05,-0.47 1.19,0.4 0.12,0.75 1.05,1.61 -0.09,2.24 -2.09,1.16 -4.28,2.07 -6.71,2.16 -1.05,0.04 -2.13,0.2 -3.16,-0.14 -1.92,-0.65 -3.03,-2.28 -3.05,-4.51 -0.02,-3.19 -0.01,-6.37 -0.01,-9.56 z" + fill="#c8dbfb" + id="path8" /> + <path + d="m -50.816031,95.21 c 0.19,2.160002 1.85,3.240002 2.82,4.740002 0.25,0.379998 0.48,0.109998 0.67,-0.16 0.21,-0.31 0.6,-1.21 1.15,-1.28 -0.35,1.38 -0.04,3.149998 0.16,4.449998 0.49,3.05 -1.22,5.64 -4.07,6.18 -3.38,0.65 -6.22,-2.21 -5.6,-5.62 0.23,-1.24 1.37,-2.5 0.77,-3.699998 -0.85,-1.7 0.54,-0.52 0.79,-0.22 1.04,1.199998 1.21,0.09 1.45,-0.55 0.24,-0.63 0.31,-1.31 0.47,-1.97 0.19,-0.770002 0.55,-1.400002 1.39,-1.870002 z" + fill="#4280f6" + id="path10" /> +</svg> 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 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + version="1.1" + id="copy" + x="0px" + y="0px" + viewBox="0 0 115.77 122.88" + style="enable-background:new 0 0 115.77 122.88" + xml:space="preserve" + sodipodi:docname="copy.svg" + inkscape:version="1.1.1 (1:1.1+202109281949+c3084ef5ed)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + ><defs + id="defs11" /><sodipodi:namedview + id="namedview9" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + showgrid="false" + inkscape:zoom="5.5501303" + inkscape:cx="57.83648" + inkscape:cy="61.439999" + inkscape:window-width="1920" + inkscape:window-height="1056" + inkscape:window-x="1920" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="g6" /><style + type="text/css" + id="style2">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g + id="g6"><path + class="st0" + d="M89.62,13.96v7.73h12.19h0.01v0.02c3.85,0.01,7.34,1.57,9.86,4.1c2.5,2.51,4.06,5.98,4.07,9.82h0.02v0.02 v73.27v0.01h-0.02c-0.01,3.84-1.57,7.33-4.1,9.86c-2.51,2.5-5.98,4.06-9.82,4.07v0.02h-0.02h-61.7H40.1v-0.02 c-3.84-0.01-7.34-1.57-9.86-4.1c-2.5-2.51-4.06-5.98-4.07-9.82h-0.02v-0.02V92.51H13.96h-0.01v-0.02c-3.84-0.01-7.34-1.57-9.86-4.1 c-2.5-2.51-4.06-5.98-4.07-9.82H0v-0.02V13.96v-0.01h0.02c0.01-3.85,1.58-7.34,4.1-9.86c2.51-2.5,5.98-4.06,9.82-4.07V0h0.02h61.7 h0.01v0.02c3.85,0.01,7.34,1.57,9.86,4.1c2.5,2.51,4.06,5.98,4.07,9.82h0.02V13.96L89.62,13.96z M79.04,21.69v-7.73v-0.02h0.02 c0-0.91-0.39-1.75-1.01-2.37c-0.61-0.61-1.46-1-2.37-1v0.02h-0.01h-61.7h-0.02v-0.02c-0.91,0-1.75,0.39-2.37,1.01 c-0.61,0.61-1,1.46-1,2.37h0.02v0.01v64.59v0.02h-0.02c0,0.91,0.39,1.75,1.01,2.37c0.61,0.61,1.46,1,2.37,1v-0.02h0.01h12.19V35.65 v-0.01h0.02c0.01-3.85,1.58-7.34,4.1-9.86c2.51-2.5,5.98-4.06,9.82-4.07v-0.02h0.02H79.04L79.04,21.69z M105.18,108.92V35.65v-0.02 h0.02c0-0.91-0.39-1.75-1.01-2.37c-0.61-0.61-1.46-1-2.37-1v0.02h-0.01h-61.7h-0.02v-0.02c-0.91,0-1.75,0.39-2.37,1.01 c-0.61,0.61-1,1.46-1,2.37h0.02v0.01v73.27v0.02h-0.02c0,0.91,0.39,1.75,1.01,2.37c0.61,0.61,1.46,1,2.37,1v-0.02h0.01h61.7h0.02 v0.02c0.91,0,1.75-0.39,2.37-1.01c0.61-0.61,1-1.46,1-2.37h-0.02V108.92L105.18,108.92z" + id="path4" + style="fill:#ffffff;fill-opacity:1" /></g></svg> 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 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> + +<svg + fill="#FFFFFF" + height="7.5005589" + width="11.248507" + version="1.1" + id="email" + viewBox="0 0 7.1975545 4.7993639" + xml:space="preserve" + xmlns="http://www.w3.org/2000/svg" + ><defs + id="defs19" /> +<g + id="g14" + transform="matrix(-0.01406065,0,0,0.01406065,7.1975543,-1.1990922)"> + <g + id="g12"> + <g + id="g10"> + <path + d="M 468.373,85.28 H 45.333 C 21.227,85.28 0,105.76 0,129.014 V 383.2 c 0,23.147 21.227,43.413 45.333,43.413 h 422.933 c 23.68,0 43.627,-19.84 43.627,-43.413 V 129.014 C 512,105.334 492.053,85.28 468.373,85.28 Z m 0,320 H 45.333 c -12.373,0 -24,-10.773 -24,-22.08 V 129.014 c 0,-11.307 11.84,-22.4 24,-22.4 h 422.933 c 11.733,0 22.293,10.667 22.293,22.4 V 383.2 h 0.107 c 10e-4,11.734 -10.453,22.08 -22.293,22.08 z" + id="path2" /> + <path + d="m 440.853,153.974 c -3.307,-4.907 -9.92,-6.187 -14.827,-2.987 L 256,264.48 85.973,151.094 c -4.907,-3.2 -11.52,-1.707 -14.72,3.2 -3.093,4.8 -1.813,11.307 2.88,14.507 l 176,117.333 c 3.627,2.347 8.213,2.347 11.84,0 l 176,-117.333 c 4.8,-3.201 6.187,-9.921 2.88,-14.827 z" + id="path4" /> + <path + d="m 143.573,257.654 c -0.107,0.107 -0.32,0.213 -0.427,0.32 L 68.48,311.307 c -4.907,3.307 -6.187,9.92 -2.88,14.827 3.307,4.907 9.92,6.187 14.827,2.88 0.107,-0.107 0.32,-0.213 0.427,-0.32 l 74.667,-53.333 c 4.907,-3.307 6.187,-9.92 2.88,-14.827 -3.308,-4.907 -9.921,-6.187 -14.828,-2.88 z" + id="path6" /> + <path + d="m 443.947,311.627 c -0.107,-0.107 -0.32,-0.213 -0.427,-0.32 l -74.667,-53.333 c -4.693,-3.52 -11.413,-2.56 -14.933,2.133 -3.52,4.693 -2.56,11.413 2.133,14.933 0.107,0.107 0.32,0.213 0.427,0.32 l 74.667,53.333 c 4.693,3.52 11.413,2.56 14.933,-2.133 3.52,-4.693 2.56,-11.413 -2.133,-14.933 z" + id="path8" /> + </g> + </g> +</g> +</svg> 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> +<svg width="61.649mm" height="61.649mm" version="1.1" viewBox="0 0 61.649 61.649" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(66.269 -15.463)" fill="#3056d3"><g transform="matrix(1.423 0 0 1.423 101.16 69.23)" fill="#3056d3"><g transform="matrix(.8229 0 0 .8229 -23.378 -2.3935)" fill="#3056d3"><path d="m-88.248-43.007a26.323 26.323 0 0 0-26.323 26.323 26.323 26.323 0 0 0 26.323 26.323 26.323 26.323 0 0 0 26.323-26.323 26.323 26.323 0 0 0-26.323-26.323zm0 2.8417a23.482 23.482 0 0 1 23.482 23.482 23.482 23.482 0 0 1-23.482 23.482 23.482 23.482 0 0 1-23.482-23.482 23.482 23.482 0 0 1 23.482-23.482z"/><g transform="matrix(.26458 0 0 .26458 -115.65 -44.085)"><path d="m33.02 64.43c0.35-0.05 2.04-0.13 2.04-0.13h25.53s3.17 0.32 3.67 0.53c2.5 1.05 3.98 1.89 6.04 3.57 0.72 0.58 4.12 4.01 4.12 4.01l51.67 57.39s1.61 1.65 1.97 1.94c1.2 0.97 2.48 1.96 3.98 2.32 0.5 0.12 2.72 0.21 2.72 0.21h27.32l-8.83-9.04s-1.31-1.65-1.44-1.94c-0.45-0.93-0.59-2.59-0.13-3.51 0.35-0.69 1.46-1.87 2.23-1.98 1.03-0.14 2.12-0.39 3.02 0.14 0.33 0.2 1.64 1.32 1.64 1.32l17.49 17.49s1.35 1.09 1.6 1.6c0.17 0.34 0.29 0.82 0.15 1.18-0.17 0.42-1.42 1.63-1.42 1.63l-0.94 0.98-15.69 16.37s-1.44 1.4-1.79 1.67c-0.76 0.6-1.99 0.89-2.96 0.9-1.03 0-2.62-1.11-3.26-1.91-0.6-0.76-1.1-2.22-0.77-3.13 0.16-0.45 1.28-1.85 1.28-1.85l11.36-11.3-29.47-0.02-1.68 0.09s-4.16-0.66-5.26-1.03c-1.63-0.56-3.44-1.82-4.75-2.93-0.39-0.33-1.8-1.92-1.8-1.92l-51.7-59.28s-2-2.06-2.43-2.43c-1.37-1.17-2-1.62-3.76-2.34-0.44-0.18-3.45-0.55-3.45-0.55l-24.13-0.22s-2.23-0.15-2.61-0.22c-1.08-0.21-2.16-1.07-2.81-1.83-0.79-0.92-0.59-3.06 0.06-4.09 0.57-0.89 2.14-1.52 3.19-1.66z"/><path d="m86.1 109.7-17.13 19.65s-2 2.06-2.43 2.43c-1.37 1.17-2 1.62-3.76 2.34-0.44 0.18-3.45 0.55-3.45 0.55l-24.13 0.22s-2.23 0.15-2.61 0.22c-1.08 0.21-2.16 1.07-2.81 1.83-0.79 0.92-0.59 3.06 0.06 4.09 0.57 0.89 2.14 1.52 3.19 1.66 0.35 0.05 2.04 0.13 2.04 0.13h25.53s3.17-0.32 3.67-0.53c2.5-1.05 3.98-1.89 6.04-3.57 0.72-0.58 4.12-4.01 4.12-4.01l17.38-19.3z"/><path d="m177.81 67.6c-0.17-0.42-1.42-1.63-1.42-1.63l-0.94-0.98-15.69-16.37s-1.44-1.4-1.79-1.67c-0.76-0.6-1.99-0.89-2.96-0.9-1.03 0-2.62 1.11-3.26 1.91-0.6 0.76-1.1 2.22-0.77 3.13 0.16 0.45 1.28 1.85 1.28 1.85l11.36 11.3-29.47 0.02-1.68-0.09s-4.16 0.66-5.26 1.03c-1.63 0.56-3.44 1.82-4.75 2.93-0.39 0.33-1.8 1.92-1.8 1.92l-18.91 21.69 5.98 5.98 18.38-20.41s1.61-1.65 1.97-1.94c1.2-0.97 2.48-1.96 3.98-2.32 0.5-0.12 2.72-0.21 2.72-0.21h27.32l-8.83 9.04s-1.31 1.65-1.44 1.94c-0.45 0.93-0.59 2.59-0.13 3.51 0.35 0.69 1.46 1.87 2.23 1.98 1.03 0.14 2.12 0.39 3.02-0.14 0.33-0.2 1.64-1.32 1.64-1.32l17.49-17.49s1.35-1.09 1.6-1.6c0.17-0.34 0.29-0.82 0.15-1.18z"/></g></g></g></g></svg> 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" + id="RSSicon" + viewBox="0 0 8 8" width="256" height="256"> + + <title>RSS feed icon + + + + + + + + + \ 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 + + + {% endif %} +
  • + +
  • +
  • + + + + +
  • +
  • + + {% include "svgs/github.svg" %} + +
  • + + +
    + + + + + + {% if hosted_sticky %} + + {% endif %} + {% if left_sticky %} +
    + Show current snapshot
    + Visualise triggers and ignored text +
    + {% endif %} + {% if right_sticky %} +
    {{ right_sticky }}
    + {% endif %} +
    +
    +
    +

    Try our Chrome extension

    +

    + + Chrome store icon + 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'] %} + + {% endif %} + {% block content %}{% endblock %} +
    + + + + + + + diff --git a/changedetectionio/templates/diff.html b/changedetectionio/templates/diff.html new file mode 100644 index 0000000000000000000000000000000000000000..f7fdf868602730f09f54d00bd30a42140a87602f --- /dev/null +++ b/changedetectionio/templates/diff.html @@ -0,0 +1,167 @@ +{% extends 'base.html' %} +{% from '_helpers.html' import render_field, render_checkbox_field, render_button %} +{% block content %} + + + +
    +
    +
    + {% if versions|length >= 1 %} + Compare + from + + to + + + {% endif %} +
    +
    + Style + + + + + + + + + + + +
    +
    + +
    + +
    + Jump +
    + + +
    + +
    + +
    +
    +
    {{watch_a.error_text_ctime|format_seconds_ago}} seconds ago
    +
    +            {{ last_error_text }}
    +        
    +
    + +
    +
    {{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago
    + Current error-ing screenshot from most recent request +
    + +
    + {% if password_enabled_and_share_is_off %} +
    Pro-tip: You can enable "share access when password is enabled" from settings
    + {% endif %} + +
    {{watch_a.snapshot_text_ctime|format_timestamp_timeago}}
    + + + + + + + + + + +
    + +
    + Diff algorithm from the amazing github.com/kpdecker/jsdiff +
    +
    +
    + For now, Differences are performed on text, not graphically, only the latest screenshot is available. +
    + {% if is_html_webdriver %} + {% if screenshot %} +
    {{watch_a.snapshot_screenshot_ctime|format_timestamp_timeago}}
    + Current screenshot from most recent request + {% else %} + No screenshot available just yet! Try rechecking the page. + {% endif %} + {% else %} + Screenshot requires Playwright/WebDriver enabled + {% endif %} +
    +
    +
    + + +

    This tool will extract text data from all of the watch history.

    + +
    + {{ render_field(extract_form.extract_regex) }} + + A RegEx is a pattern that identifies exactly which part inside of the text that you want to extract.
    + +

    + For example, to extract only the numbers from text ‐
    + Raw text: Temperature 5.5°C in Sydney
    + RegEx to extract: Temperature ([0-9\.]+)
    +

    +

    + Be sure to test your RegEx here. +

    +

    + Each RegEx group bracket () will be in its own column, the first column value is always the date. +

    +
    +
    +
    + {{ render_button(extract_form.extract_submit_button) }} +
    +
    +
    +
    + + + + + + + +{% endblock %} diff --git a/changedetectionio/templates/edit.html b/changedetectionio/templates/edit.html new file mode 100644 index 0000000000000000000000000000000000000000..8fd98d60dabad0fb0bdcbe2ea78480700c138d24 --- /dev/null +++ b/changedetectionio/templates/edit.html @@ -0,0 +1,483 @@ +{% extends 'base.html' %} +{% block content %} +{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_playwright_type_watches_warning, render_conditions_fieldlist_of_formfields_as_table %} +{% from '_common_fields.html' import render_common_settings_form %} + + + + + + + + + + + + +{% if playwright_enabled %} + +{% endif %} + +{% set has_tag_filters_extra="WARNING: Watch has tag/groups set with special filters\n" if has_special_tag_options else '' %} + + +
    + +
    + +
    + +
    +
    + + +
    +
    +
    + {{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }} +
    Some sites use JavaScript to create the content, for this you should use the Chrome/WebDriver Fetcher
    +
    Variables are supported in the URL (help and examples here).
    +
    +
    + {{ render_field(form.processor) }} +
    +
    + {{ render_field(form.title, class="m-d") }} +
    +
    + {{ render_field(form.tags) }} + Organisational tag/group name used in the main listing page +
    +
    + + {{ render_checkbox_field(form.time_between_check_use_default, class="use-default-timecheck") }} +
    +
    + {{ render_field(form.time_between_check, class="time-check-widget") }} + + + The interval/amount of time between each check. + +
    +
    + +
    + {{ render_time_schedule_form(form, available_timezones, timezone_default_config) }} +
    +
    +
    +
    +
    + {{ render_checkbox_field(form.extract_title_as_title) }} +
    +
    + {{ render_checkbox_field(form.filter_failure_notification_send) }} + + Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore. + +
    +
    +
    + +
    +
    + {{ render_field(form.fetch_backend, class="fetch-backend") }} + +

    Use the Basic method (default) where your watched site doesn't need Javascript to render.

    +

    The Chrome/Javascript method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.

    + Tip: Connect using Bright Data and Oxylabs Proxies, find out more here. +
    +
    + {% if form.proxy %} +
    +
    {{ form.proxy.label }} Check/Scan all
    +
    {{ form.proxy(class="fetch-backend-proxy") }}
    + + Choose a proxy for this watch + +
    + {% endif %} + + +
    +
    + {{ render_field(form.webdriver_delay) }} +
    + If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here. +
    + This will wait n seconds before extracting the text. + {% if using_global_webdriver_wait %} +
    Using the current global default settings + {% endif %} +
    +
    + + +
    + +
    + + +
    + + +
    + +
    +
    + +
    + {% if watch_needs_selenium_or_playwright %} + {# Only works with playwright #} + {% if system_has_playwright_configured %} + +
    +
    + + + + + + +
    + +
    +
    + + +

    Click here to Start

    +
    + Please allow 10-15 seconds for the browser to connect.
    +
    + +
    + + +
    +
    +
    + Press "Play" to start. (?) + {{ render_field(form.browser_steps) }} +
    +
    +
    +
    + {% else %} + {# it's configured to use selenium or chrome but system says its not configured #} + {{ playwright_warning() }} + {% if system_has_webdriver_configured %} + Selenium/Webdriver cant be used here because it wont fetch screenshots reliably. + {% endif %} + {% endif %} + {% else %} + {# "This functionality needs chrome.." #} + {{ only_playwright_type_watches_warning() }} + {% endif %} +
    + + +
    +
    +
    + {{ render_checkbox_field(form.notification_muted) }} +
    + {% if watch_needs_selenium_or_playwright %} +
    + {{ 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 %} +
    + Look out! + 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) }} +
    +
    +
    + + {% if watch['processor'] == 'text_json_diff' %} + +
    + +
    + {{ render_field(form.conditions_match_logic) }} + {{ render_conditions_fieldlist_of_formfields_as_table(form.conditions) }} +
    + +

    Use the verify (✓) button to test if a condition passes against the current snapshot.

    + Read a quick tutorial about using conditional web page changes here.
    +
    +
    +
    +
    + Activate preview +
    +
    +
    + Pro-tips:
    +
      +
    • + Use the preview page to see your filters and triggers highlighted. +
    • +
    • + Some sites use JavaScript to create the content, for this you should use the Chrome/WebDriver Fetcher +
    • +
    +
    + +{% include "edit/include_subtract.html" %} +
    +
    +

    Text filtering

    + Limit trigger/ignore/block/extract to;
    + {{ render_checkbox_field(form.filter_text_added) }} + {{ render_checkbox_field(form.filter_text_replaced) }} + {{ render_checkbox_field(form.filter_text_removed) }} + Note: Depending on the length and similarity of the text on each line, the algorithm may consider an addition instead of replacement for example.
    +  So it's always better to select Added+Replaced when you're interested in new content.
    +  When content is merely moved in a list, it will also trigger an addition, consider enabling Only trigger when unique lines appear +
    +
    + {{ render_checkbox_field(form.check_unique_lines) }} + Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch. +
    +
    + {{ render_checkbox_field(form.remove_duplicate_lines) }} + Remove duplicate lines of text +
    +
    + {{ render_checkbox_field(form.sort_text_alphabetically) }} + Helps reduce changes detected caused by sites shuffling lines around, combine with check unique lines below. +
    +
    + {{ render_checkbox_field(form.trim_text_whitespace) }} + Remove any whitespace before and after each line of text +
    + {% include "edit/text-options.html" %} +
    +
    + +
    +
    + + {% endif %} + {# rendered sub Template #} + {% if extra_form_content %} +
    + {{ extra_form_content|safe }} +
    + {% endif %} + {% if watch['processor'] == 'text_json_diff' %} +
    + + +
    +
    + {% if watch_needs_selenium_or_playwright %} + {% if system_has_playwright_configured %} + + The Visual Selector tool lets you select the text elements that will be used for the change detection. It automatically fills-in the filters in the "CSS/JSONPath/JQ/XPath Filters" box of the Filters & Triggers tab. Use Shift+Click to select multiple items. + + +
    + Clear selection + + One moment, fetching screenshot and element information.. +
    + +
    Currently: Loading...
    + {% else %} + {# The watch needed chrome but system says that playwright is not ready #} + {{ playwright_warning() }} + {% endif %} + {% if system_has_webdriver_configured %} + Selenium/Webdriver cant be used here because it wont fetch screenshots reliably. + {% endif %} + {% else %} + {# "This functionality needs chrome.." #} + {{ only_playwright_type_watches_warning() }} + {% endif %} +
    +
    +
    + {% endif %} +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Check count{{ "{:,}".format( watch.check_count) }}
    Consecutive filter failures{{ "{:,}".format( watch.consecutive_filter_failures) }}
    History length{{ "{:,}".format(watch.history|length) }}
    Last fetch duration{{ watch.fetch_time }}s
    Notification alert count{{ watch.notification_alert_count }}
    Server type reply{{ watch.get('remote_server_reply') }}
    + + {% if ui_edit_stats_extras %} +
    + {{ ui_edit_stats_extras|safe }} +
    + {% endif %} + + {% if watch.history_n %} +

    + Download latest HTML snapshot +

    + {% endif %} + +
    +
    +
    +
    + {{ render_button(form.save_button) }} + Delete + {% if watch.history_n %}Clear History{% endif %} + Clone & Edit +
    +
    +
    +
    +
    + +{% endblock %} diff --git a/changedetectionio/templates/edit/include_subtract.html b/changedetectionio/templates/edit/include_subtract.html new file mode 100644 index 0000000000000000000000000000000000000000..3ee694acff7f536f79b485c4cc0b5808351d853f --- /dev/null +++ b/changedetectionio/templates/edit/include_subtract.html @@ -0,0 +1,55 @@ +
    + {% set field = render_field(form.include_filters, + rows=5, + placeholder=has_tag_filters_extra+"#example +xpath://body/div/span[contains(@class, 'example-class')]", + class="m-d") + %} + {{ field }} + {% if '/text()' in field %} + Note!: //text() function does not work where the <element> contains <![CDATA[]]>
    + {% endif %} + One CSS, xPath 1 & 2, JSON Path/JQ selector per line, any rules that matches will be used.
    + Show advanced help and tips
    + + +
    +
    +
    + {{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header +footer +nav +.stockticker +//*[contains(text(), 'Advertisement')]") }} + +
      +
    • Remove HTML element(s) by CSS and XPath selectors before text conversion.
    • +
    • Don't paste HTML here, use only CSS and XPath selectors
    • +
    • Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.
    • +
    +
    +
    diff --git a/changedetectionio/templates/edit/text-options.html b/changedetectionio/templates/edit/text-options.html new file mode 100644 index 0000000000000000000000000000000000000000..73118cbf4040c696a10032f8a8c6ac8192eed20a --- /dev/null +++ b/changedetectionio/templates/edit/text-options.html @@ -0,0 +1,69 @@ + +
    +
    + {{ render_field(form.trigger_text, rows=5, placeholder="Some text to wait for in a line +/some.regex\d{2}/ for case-INsensitive regex +") }} + +
      +
    • Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.
    • +
    • Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this watch
    • +
    • Each line is processed separately (think of each line as "OR")
    • +
    • Note: Wrap in forward slash / to use regex example: /foo\d/
    • +
    +
    +
    +
    +
    + {{ render_field(form.ignore_text, rows=5, placeholder="Some text to ignore in a line +/some.regex\d{2}/ for case-INsensitive regex +") }} + +
      +
    • Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)
    • +
    • Each line processed separately, any line matching will be ignored (removed before creating the checksum)
    • +
    • Regular Expression support, wrap the entire line in forward slash /regex/
    • +
    • Changing this will affect the comparison checksum which may trigger an alert
    • +
    +
    + +
    + +
    +
    + {{ render_field(form.text_should_not_be_present, rows=5, placeholder="For example: Out of stock +Sold out +Not in stock +Unavailable") }} + +
      +
    • Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again
    • +
    • Block text is processed from the result-text that comes out of any CSS/JSON Filters for this watch
    • +
    • All lines here must not exist (think of each line as "OR")
    • +
    • Note: Wrap in forward slash / to use regex example: /foo\d/
    • +
    +
    +
    +
    +
    +
    + {{ render_field(form.extract_text, rows=5, placeholder="/.+?\d+ comments.+?/ + or +keyword") }} + +
      +
    • Extracts text in the final output (line by line) after other filters using regular expressions or string match; +
        +
      • Regular expression ‐ example /reports.+?2022/i
      • +
      • Don't forget to consider the white-space at the start of a line /.+?reports.+?2022/i
      • +
      • Use //(?aiLmsux)) type flags (more information here)
      • +
      • Keyword example ‐ example Out of stock
      • +
      • Use groups to extract just that text ‐ example /reports.+?(\d+)/i returns a list of years only
      • +
      • Example - match lines containing a keyword /.*icecream.*/
      • +
      +
    • +
    • One line per regular-expression/string match
    • +
    +
    +
    +
    diff --git a/changedetectionio/templates/login.html b/changedetectionio/templates/login.html new file mode 100644 index 0000000000000000000000000000000000000000..dac94dfa8d4bb33e44c5c43e172416b2641c10ed --- /dev/null +++ b/changedetectionio/templates/login.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} + +{% block content %} + + +{% endblock %} diff --git a/changedetectionio/templates/preview.html b/changedetectionio/templates/preview.html new file mode 100644 index 0000000000000000000000000000000000000000..826fc041a666df85d543a530e4bec7bb4d34e7f8 --- /dev/null +++ b/changedetectionio/templates/preview.html @@ -0,0 +1,104 @@ +{% extends 'base.html' %} + +{% block content %} + + + + + + {% if versions|length >= 2 %} +
    +
    +
    + + + +
    +
    +
    + Keyboard: + ← Previous   + → Next +
    + {% endif %} + +
    + +
    + + +
    +
    +
    {{ watch.error_text_ctime|format_seconds_ago }} seconds ago
    +
    +            {{ last_error_text }}
    +        
    +
    + +
    +
    {{ watch.snapshot_error_screenshot_ctime|format_seconds_ago }} seconds ago +
    + Current erroring screenshot from most recent request +
    + +
    +
    {{ current_version|format_timestamp_timeago }}
    + Pro-tip: Highlight text to add to ignore filters + + + + + + + +
    +
    +{{ content }}
    +                        
    +
    +
    + +
    +
    + For now, Differences are performed on text, not graphically, only the latest screenshot is available. +
    +
    + {% if is_html_webdriver %} + {% if screenshot %} +
    {{ watch.snapshot_screenshot_ctime|format_timestamp_timeago }}
    + Current screenshot from most recent request + {% else %} + No screenshot available just yet! Try rechecking the page. + {% endif %} + {% else %} + Screenshot requires Playwright/WebDriver enabled + {% endif %} +
    +
    +{% endblock %} diff --git a/changedetectionio/templates/svgs/dark-mode-toggle-icon.svg b/changedetectionio/templates/svgs/dark-mode-toggle-icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..e83c83a2bafbed1a007b83b26cbd6059da93dff0 --- /dev/null +++ b/changedetectionio/templates/svgs/dark-mode-toggle-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/changedetectionio/templates/svgs/github.svg b/changedetectionio/templates/svgs/github.svg new file mode 100644 index 0000000000000000000000000000000000000000..6e7f97a246b2868067f0238f6534c887152a0496 --- /dev/null +++ b/changedetectionio/templates/svgs/github.svg @@ -0,0 +1,6 @@ + diff --git a/changedetectionio/templates/svgs/light-mode-toggle-icon.svg b/changedetectionio/templates/svgs/light-mode-toggle-icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..4fa0e4ca8462f5446a512cb5ea2bdbd277d439d7 --- /dev/null +++ b/changedetectionio/templates/svgs/light-mode-toggle-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/changedetectionio/templates/svgs/search-icon.svg b/changedetectionio/templates/svgs/search-icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..93860c1803b3238192798b69960347ec7202819d --- /dev/null +++ b/changedetectionio/templates/svgs/search-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/changedetectionio/tests/__init__.py b/changedetectionio/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..085b3d78caa449246fa27bc8ebf3559ed6380577 --- /dev/null +++ b/changedetectionio/tests/__init__.py @@ -0,0 +1,2 @@ +"""Tests for the app.""" + diff --git a/changedetectionio/tests/apprise/test_apprise_asset.py b/changedetectionio/tests/apprise/test_apprise_asset.py new file mode 100644 index 0000000000000000000000000000000000000000..6e86868ddcb355ac944e6bc3d80004f97f590220 --- /dev/null +++ b/changedetectionio/tests/apprise/test_apprise_asset.py @@ -0,0 +1,24 @@ +import pytest +from apprise import AppriseAsset + +from changedetectionio.apprise_asset import ( + APPRISE_APP_DESC, + APPRISE_APP_ID, + APPRISE_APP_URL, + APPRISE_AVATAR_URL, +) + + +@pytest.fixture(scope="function") +def apprise_asset() -> AppriseAsset: + from changedetectionio.apprise_asset import apprise_asset + + return apprise_asset + + +def test_apprise_asset_init(apprise_asset: AppriseAsset): + assert isinstance(apprise_asset, AppriseAsset) + assert apprise_asset.app_id == APPRISE_APP_ID + assert apprise_asset.app_desc == APPRISE_APP_DESC + assert apprise_asset.app_url == APPRISE_APP_URL + assert apprise_asset.image_url_logo == APPRISE_AVATAR_URL diff --git a/changedetectionio/tests/apprise/test_apprise_custom_api_call.py b/changedetectionio/tests/apprise/test_apprise_custom_api_call.py new file mode 100644 index 0000000000000000000000000000000000000000..45271051d030dd4b2d61f881313c0bd2b0988db3 --- /dev/null +++ b/changedetectionio/tests/apprise/test_apprise_custom_api_call.py @@ -0,0 +1,211 @@ +import json +from unittest.mock import patch + +import pytest +import requests +from apprise.utils.parse import parse_url as apprise_parse_url + +from ...apprise_plugin.custom_handlers import ( + _get_auth, + _get_headers, + _get_params, + apprise_http_custom_handler, + SUPPORTED_HTTP_METHODS, +) + + +@pytest.mark.parametrize( + "url,expected_auth", + [ + ("get://user:pass@localhost:9999", ("user", "pass")), + ("get://user@localhost:9999", "user"), + ("get://localhost:9999", ""), + ("get://user%20name:pass%20word@localhost:9999", ("user name", "pass word")), + ], +) +def test_get_auth(url, expected_auth): + """Test authentication extraction with various URL formats.""" + parsed_url = apprise_parse_url(url) + assert _get_auth(parsed_url) == expected_auth + + +@pytest.mark.parametrize( + "url,body,expected_content_type", + [ + ( + "get://localhost:9999?+content-type=application/xml", + "test", + "application/xml", + ), + ("get://localhost:9999", '{"key": "value"}', "application/json; charset=utf-8"), + ("get://localhost:9999", "plain text", None), + ("get://localhost:9999?+content-type=text/plain", "test", "text/plain"), + ], +) +def test_get_headers(url, body, expected_content_type): + """Test header extraction and content type detection.""" + parsed_url = apprise_parse_url(url) + headers = _get_headers(parsed_url, body) + + if expected_content_type: + assert headers.get("Content-Type") == expected_content_type + + +@pytest.mark.parametrize( + "url,expected_params", + [ + ("get://localhost:9999?param1=value1", {"param1": "value1"}), + ("get://localhost:9999?param1=value1&-param2=ignored", {"param1": "value1"}), + ("get://localhost:9999?param1=value1&+header=test", {"param1": "value1"}), + ( + "get://localhost:9999?encoded%20param=encoded%20value", + {"encoded param": "encoded value"}, + ), + ], +) +def test_get_params(url, expected_params): + """Test parameter extraction with URL encoding and exclusion logic.""" + parsed_url = apprise_parse_url(url) + params = _get_params(parsed_url) + assert dict(params) == expected_params + + +@pytest.mark.parametrize( + "url,schema,method", + [ + ("get://localhost:9999", "get", "GET"), + ("post://localhost:9999", "post", "POST"), + ("delete://localhost:9999", "delete", "DELETE"), + ], +) +@patch("requests.request") +def test_apprise_custom_api_call_success(mock_request, url, schema, method): + """Test successful API calls with different HTTP methods and schemas.""" + mock_request.return_value.raise_for_status.return_value = None + + meta = {"url": url, "schema": schema} + result = apprise_http_custom_handler( + body="test body", title="Test Title", notify_type="info", meta=meta + ) + + assert result is True + mock_request.assert_called_once() + + call_args = mock_request.call_args + assert call_args[1]["method"] == method.upper() + assert call_args[1]["url"].startswith("http") + + +@patch("requests.request") +def test_apprise_custom_api_call_with_auth(mock_request): + """Test API call with authentication.""" + mock_request.return_value.raise_for_status.return_value = None + + url = "get://user:pass@localhost:9999/secure" + meta = {"url": url, "schema": "get"} + + result = apprise_http_custom_handler( + body=json.dumps({"key": "value"}), + title="Secure Test", + notify_type="info", + meta=meta, + ) + + assert result is True + mock_request.assert_called_once() + call_args = mock_request.call_args + assert call_args[1]["auth"] == ("user", "pass") + + +@pytest.mark.parametrize( + "exception_type,expected_result", + [ + (requests.RequestException, False), + (requests.HTTPError, False), + (Exception, False), + ], +) +@patch("requests.request") +def test_apprise_custom_api_call_failure(mock_request, exception_type, expected_result): + """Test various failure scenarios.""" + url = "get://localhost:9999/error" + meta = {"url": url, "schema": "get"} + + # Simulate different types of exceptions + mock_request.side_effect = exception_type("Error occurred") + + result = apprise_http_custom_handler( + body="error body", title="Error Test", notify_type="error", meta=meta + ) + + assert result == expected_result + + +def test_invalid_url_parsing(): + """Test handling of invalid URL parsing.""" + meta = {"url": "invalid://url", "schema": "invalid"} + result = apprise_http_custom_handler( + body="test", title="Invalid URL", notify_type="info", meta=meta + ) + + assert result is False + + +@pytest.mark.parametrize( + "schema,expected_method", + [ + (http_method, http_method.upper()) + for http_method in SUPPORTED_HTTP_METHODS + ], +) +@patch("requests.request") +def test_http_methods(mock_request, schema, expected_method): + """Test all supported HTTP methods.""" + mock_request.return_value.raise_for_status.return_value = None + + url = f"{schema}://localhost:9999" + + result = apprise_http_custom_handler( + body="test body", + title="Test Title", + notify_type="info", + meta={"url": url, "schema": schema}, + ) + + assert result is True + mock_request.assert_called_once() + + call_args = mock_request.call_args + assert call_args[1]["method"] == expected_method + + +@pytest.mark.parametrize( + "input_schema,expected_method", + [ + (f"{http_method}s", http_method.upper()) + for http_method in SUPPORTED_HTTP_METHODS + ], +) +@patch("requests.request") +def test_https_method_conversion( + mock_request, input_schema, expected_method +): + """Validate that methods ending with 's' use HTTPS and correct HTTP method.""" + mock_request.return_value.raise_for_status.return_value = None + + url = f"{input_schema}://localhost:9999" + + result = apprise_http_custom_handler( + body="test body", + title="Test Title", + notify_type="info", + meta={"url": url, "schema": input_schema}, + ) + + assert result is True + mock_request.assert_called_once() + + call_args = mock_request.call_args + + assert call_args[1]["method"] == expected_method + assert call_args[1]["url"].startswith("https") diff --git a/changedetectionio/tests/conftest.py b/changedetectionio/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..3e7069caacae0641c0f3707aee65a27fa8e9bd7b --- /dev/null +++ b/changedetectionio/tests/conftest.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +import psutil +import time +from threading import Thread + +import pytest +from changedetectionio import changedetection_app +from changedetectionio import store +import os +import sys +from loguru import logger + +from changedetectionio.tests.util import live_server_setup, new_live_server_setup + +# https://github.com/pallets/flask/blob/1.1.2/examples/tutorial/tests/test_auth.py +# Much better boilerplate than the docs +# https://www.python-boilerplate.com/py3+flask+pytest/ + +global app + +# https://loguru.readthedocs.io/en/latest/resources/migration.html#replacing-caplog-fixture-from-pytest-library +# Show loguru logs only if CICD pytest fails. +from loguru import logger +@pytest.fixture +def reportlog(pytestconfig): + logging_plugin = pytestconfig.pluginmanager.getplugin("logging-plugin") + handler_id = logger.add(logging_plugin.report_handler, format="{message}") + yield + logger.remove(handler_id) + + +def track_memory(memory_usage, ): + process = psutil.Process(os.getpid()) + while not memory_usage["stop"]: + current_rss = process.memory_info().rss + memory_usage["peak"] = max(memory_usage["peak"], current_rss) + time.sleep(0.01) # Adjust the sleep time as needed + +@pytest.fixture(scope='function') +def measure_memory_usage(request): + memory_usage = {"peak": 0, "stop": False} + tracker_thread = Thread(target=track_memory, args=(memory_usage,)) + tracker_thread.start() + + yield + + memory_usage["stop"] = True + tracker_thread.join() + + # Note: ru_maxrss is in kilobytes on Unix-based systems + max_memory_used = memory_usage["peak"] / 1024 # Convert to MB + s = f"Peak memory used by the test {request.node.fspath} - '{request.node.name}': {max_memory_used:.2f} MB" + logger.debug(s) + + with open("test-memory.log", 'a') as f: + f.write(f"{s}\n") + + # Assert that the memory usage is less than 200MB +# assert max_memory_used < 150, f"Memory usage exceeded 200MB: {max_memory_used:.2f} MB" + + +def cleanup(datastore_path): + import glob + # Unlink test output files + + for g in ["*.txt", "*.json", "*.pdf"]: + files = glob.glob(os.path.join(datastore_path, g)) + for f in files: + if 'proxies.json' in f: + # Usually mounted by docker container during test time + continue + if os.path.isfile(f): + os.unlink(f) + +@pytest.fixture(scope='function', autouse=True) +def prepare_test_function(live_server): + + routes = [rule.rule for rule in live_server.app.url_map.iter_rules()] + if '/test-random-content-endpoint' not in routes: + logger.debug("Setting up test URL routes") + new_live_server_setup(live_server) + + + yield + # Then cleanup/shutdown + live_server.app.config['DATASTORE'].data['watching']={} + time.sleep(0.3) + live_server.app.config['DATASTORE'].data['watching']={} + + +@pytest.fixture(scope='session') +def app(request): + """Create application for the tests.""" + datastore_path = "./test-datastore" + + # So they don't delay in fetching + os.environ["MINIMUM_SECONDS_RECHECK_TIME"] = "0" + try: + os.mkdir(datastore_path) + except FileExistsError: + pass + + cleanup(datastore_path) + + app_config = {'datastore_path': datastore_path, 'disable_checkver' : True} + cleanup(app_config['datastore_path']) + + logger_level = 'TRACE' + + logger.remove() + log_level_for_stdout = { 'DEBUG', '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}, + ]) + + datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], include_default_watches=False) + app = changedetection_app(app_config, datastore) + + # Disable CSRF while running tests + app.config['WTF_CSRF_ENABLED'] = False + app.config['STOP_THREADS'] = True + + def teardown(): + # Stop all threads and services + datastore.stop_thread = True + app.config.exit.set() + + # Shutdown workers gracefully before loguru cleanup + try: + from changedetectionio import worker_handler + worker_handler.shutdown_workers() + except Exception: + pass + + # Stop socket server threads + try: + from changedetectionio.flask_app import socketio_server + if socketio_server and hasattr(socketio_server, 'shutdown'): + socketio_server.shutdown() + except Exception: + pass + + # Give threads a moment to finish their shutdown + import time + time.sleep(0.1) + + # Remove all loguru handlers to prevent "closed file" errors + logger.remove() + + # Cleanup files + cleanup(app_config['datastore_path']) + + + request.addfinalizer(teardown) + yield app diff --git a/changedetectionio/tests/custom_browser_url/__init__.py b/changedetectionio/tests/custom_browser_url/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f4572339b970c63ed56fd068602d35e2be8933c5 --- /dev/null +++ b/changedetectionio/tests/custom_browser_url/__init__.py @@ -0,0 +1 @@ +# placeholder \ No newline at end of file diff --git a/changedetectionio/tests/custom_browser_url/test_custom_browser_url.py b/changedetectionio/tests/custom_browser_url/test_custom_browser_url.py new file mode 100644 index 0000000000000000000000000000000000000000..6ec4205ee96ca98329ed0bd375de5e8fe3899d1f --- /dev/null +++ b/changedetectionio/tests/custom_browser_url/test_custom_browser_url.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +import os + +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks + +def do_test(client, live_server, make_test_use_extra_browser=False): + + # Grep for this string in the logs? + test_url = "https://changedetection.io/ci-test.html?non-custom-default=true" + # "non-custom-default" should not appear in the custom browser connection + custom_browser_name = 'custom browser URL' + + # needs to be set and something like 'ws://127.0.0.1:3000' + assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" + + ##################### + res = client.post( + url_for("settings.settings_page"), + data={"application-empty_pages_are_a_change": "", + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_webdriver", + 'requests-extra_browsers-0-browser_connection_url': 'ws://sockpuppetbrowser-custom-url:3000', + 'requests-extra_browsers-0-browser_name': custom_browser_name + }, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + if make_test_use_extra_browser: + + # So the name should appear in the edit page under "Request" > "Fetch Method" + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + follow_redirects=True + ) + assert b'custom browser URL' in res.data + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + # 'run_customer_browser_url_tests.sh' will search for this string to know if we hit the right browser container or not + "url": "https://changedetection.io/ci-test.html?custom-browser-search-string=1", + "tags": "", + "headers": "", + 'fetch_backend': f"extra_browser_{custom_browser_name}", + 'webdriver_js_execute_code': '' + }, + follow_redirects=True + ) + + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + # Force recheck + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + assert b'cool it works' in res.data + + +# Requires playwright to be installed +def test_request_via_custom_browser_url(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + # We do this so we can grep the logs of the custom container and see if the request actually went through that container + do_test(client, live_server, make_test_use_extra_browser=True) + + +def test_request_not_via_custom_browser_url(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + # We do this so we can grep the logs of the custom container and see if the request actually went through that container + do_test(client, live_server, make_test_use_extra_browser=False) diff --git a/changedetectionio/tests/fetchers/__init__.py b/changedetectionio/tests/fetchers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..085b3d78caa449246fa27bc8ebf3559ed6380577 --- /dev/null +++ b/changedetectionio/tests/fetchers/__init__.py @@ -0,0 +1,2 @@ +"""Tests for the app.""" + diff --git a/changedetectionio/tests/fetchers/conftest.py b/changedetectionio/tests/fetchers/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..9a4bf56bccae80b022fe170097d9accefd8f9f1f --- /dev/null +++ b/changedetectionio/tests/fetchers/conftest.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 + +from .. import conftest diff --git a/changedetectionio/tests/fetchers/test_content.py b/changedetectionio/tests/fetchers/test_content.py new file mode 100644 index 0000000000000000000000000000000000000000..e09781c5553adba8320586a909a8e67de5f9b614 --- /dev/null +++ b/changedetectionio/tests/fetchers/test_content.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks +import logging + +# Requires playwright to be installed +def test_fetch_webdriver_content(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + + ##################### + res = client.post( + url_for("settings.settings_page"), + data={"application-empty_pages_are_a_change": "", + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_webdriver"}, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": "https://changedetection.io/ci-test.html"}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + logging.getLogger().info("Looking for correct fetched HTML (text) from server") + + assert b'cool it works' in res.data diff --git a/changedetectionio/tests/fetchers/test_custom_js_before_content.py b/changedetectionio/tests/fetchers/test_custom_js_before_content.py new file mode 100644 index 0000000000000000000000000000000000000000..cb4d6286ca04b55190cc80c87696af35ea6ea84f --- /dev/null +++ b/changedetectionio/tests/fetchers/test_custom_js_before_content.py @@ -0,0 +1,56 @@ +import os +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client + + +def test_execute_custom_js(client, live_server, measure_memory_usage): + + # live_server_setup(live_server) # Setup on conftest per function + assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" + + test_url = url_for('test_interactive_html_endpoint', _external=True) + test_url = test_url.replace('localhost.localdomain', 'cdio') + test_url = test_url.replace('localhost', 'cdio') + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + + assert b"Watch added in Paused state, saving will unpause" in res.data + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), + data={ + "url": test_url, + "tags": "", + 'fetch_backend': "html_webdriver", + 'webdriver_js_execute_code': 'document.querySelector("button[name=test-button]").click();', + 'headers': "testheader: yes\buser-agent: MyCustomAgent", + }, + follow_redirects=True + ) + assert b"unpaused" in res.data + wait_for_all_checks(client) + + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, "Watch history had atleast 1 (everything fetched OK)" + + assert b"This text should be removed" not in res.data + + # Check HTML conversion detected and workd + res = client.get( + url_for("ui.ui_views.preview_page", uuid=uuid), + follow_redirects=True + ) + assert b"This text should be removed" not in res.data + assert b"I smell JavaScript because the button was pressed" in res.data + + assert b"testheader: yes" in res.data + assert b"user-agent: mycustomagent" in res.data + + client.get( + url_for("ui.form_delete", uuid="all"), + follow_redirects=True + ) \ No newline at end of file diff --git a/changedetectionio/tests/import/spreadsheet.xlsx b/changedetectionio/tests/import/spreadsheet.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d25b933b465acd508f7803e3b5f3c68186b510f0 Binary files /dev/null and b/changedetectionio/tests/import/spreadsheet.xlsx differ diff --git a/changedetectionio/tests/itemprop_test_examples/README.md b/changedetectionio/tests/itemprop_test_examples/README.md new file mode 100644 index 0000000000000000000000000000000000000000..83db8c01f2f39c7e64160f8b1c8f9fa55bd1f662 --- /dev/null +++ b/changedetectionio/tests/itemprop_test_examples/README.md @@ -0,0 +1,6 @@ +# A list of real world examples! + +Always the price should be 666.66 for our tests + +see test_restock_itemprop.py::test_special_prop_examples + diff --git a/changedetectionio/tests/itemprop_test_examples/a.txt b/changedetectionio/tests/itemprop_test_examples/a.txt new file mode 100644 index 0000000000000000000000000000000000000000..69eaa9aec13b05c02ad5bd3eb78b6c3c0052eedf --- /dev/null +++ b/changedetectionio/tests/itemprop_test_examples/a.txt @@ -0,0 +1,25 @@ +
    $155.55
    +
    + + \ No newline at end of file diff --git a/changedetectionio/tests/proxy_list/__init__.py b/changedetectionio/tests/proxy_list/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..085b3d78caa449246fa27bc8ebf3559ed6380577 --- /dev/null +++ b/changedetectionio/tests/proxy_list/__init__.py @@ -0,0 +1,2 @@ +"""Tests for the app.""" + diff --git a/changedetectionio/tests/proxy_list/conftest.py b/changedetectionio/tests/proxy_list/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..0adb1c43060836c9edd436a913eec4bd0afea2a5 --- /dev/null +++ b/changedetectionio/tests/proxy_list/conftest.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +from .. import conftest + +#def pytest_addoption(parser): +# parser.addoption("--url_suffix", action="store", default="identifier for request") + + +#def pytest_generate_tests(metafunc): +# # This is called for every test. Only get/set command line arguments +# # if the argument is specified in the list of test "fixturenames". +# option_value = metafunc.config.option.url_suffix +# if 'url_suffix' in metafunc.fixturenames and option_value is not None: +# metafunc.parametrize("url_suffix", [option_value]) \ No newline at end of file diff --git a/changedetectionio/tests/proxy_list/proxies.json-example b/changedetectionio/tests/proxy_list/proxies.json-example new file mode 100644 index 0000000000000000000000000000000000000000..b3ddfe8aa7bf3643265b240a484248da0b73a78c --- /dev/null +++ b/changedetectionio/tests/proxy_list/proxies.json-example @@ -0,0 +1,10 @@ +{ + "proxy-one": { + "label": "Proxy One", + "url": "http://squid-one:3128" + }, + "proxy-two": { + "label": "Proxy Two", + "url": "http://squid-two:3128" + } +} diff --git a/changedetectionio/tests/proxy_list/squid-auth.conf b/changedetectionio/tests/proxy_list/squid-auth.conf new file mode 100644 index 0000000000000000000000000000000000000000..2f6d990537108a031323da23eca7db155ce938fd --- /dev/null +++ b/changedetectionio/tests/proxy_list/squid-auth.conf @@ -0,0 +1,48 @@ +acl localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN) +acl localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN) +acl localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN) +acl localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines +acl localnet src 172.16.0.0/12 # RFC 1918 local private network (LAN) +acl localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN) +acl localnet src fc00::/7 # RFC 4193 local private network range +acl localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines +acl localnet src 159.65.224.174 +acl SSL_ports port 443 +acl Safe_ports port 80 # http +acl Safe_ports port 21 # ftp +acl Safe_ports port 443 # https +acl Safe_ports port 70 # gopher +acl Safe_ports port 210 # wais +acl Safe_ports port 1025-65535 # unregistered ports +acl Safe_ports port 280 # http-mgmt +acl Safe_ports port 488 # gss-http +acl Safe_ports port 591 # filemaker +acl Safe_ports port 777 # multiling http +acl CONNECT method CONNECT + +http_access deny !Safe_ports +http_access deny CONNECT !SSL_ports +#http_access allow localhost manager +http_access deny manager +#http_access allow localhost +#http_access allow localnet + +auth_param basic program /usr/lib/squid3/basic_ncsa_auth /etc/squid3/passwords +auth_param basic realm proxy +acl authenticated proxy_auth REQUIRED +http_access allow authenticated +http_access deny all + + +http_port 3128 +coredump_dir /var/spool/squid +refresh_pattern ^ftp: 1440 20% 10080 +refresh_pattern ^gopher: 1440 0% 1440 +refresh_pattern -i (/cgi-bin/|\?) 0 0% 0 +refresh_pattern \/(Packages|Sources)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims +refresh_pattern \/Release(|\.gpg)$ 0 0% 0 refresh-ims +refresh_pattern \/InRelease$ 0 0% 0 refresh-ims +refresh_pattern \/(Translation-.*)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims +refresh_pattern . 0 20% 4320 +logfile_rotate 0 + diff --git a/changedetectionio/tests/proxy_list/squid-passwords.txt b/changedetectionio/tests/proxy_list/squid-passwords.txt new file mode 100644 index 0000000000000000000000000000000000000000..357aa81ab09312b4ad279ed6bc341aad9292f432 --- /dev/null +++ b/changedetectionio/tests/proxy_list/squid-passwords.txt @@ -0,0 +1 @@ +test:$apr1$xvhFolTA$E/kz5/Rw1ewcyaSUdwqZs. diff --git a/changedetectionio/tests/proxy_list/squid.conf b/changedetectionio/tests/proxy_list/squid.conf new file mode 100644 index 0000000000000000000000000000000000000000..615b154d9d6e337572c3811320a8b7cbaeb10797 --- /dev/null +++ b/changedetectionio/tests/proxy_list/squid.conf @@ -0,0 +1,41 @@ +acl localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN) +acl localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN) +acl localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN) +acl localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines +acl localnet src 172.16.0.0/12 # RFC 1918 local private network (LAN) +acl localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN) +acl localnet src fc00::/7 # RFC 4193 local private network range +acl localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines +acl localnet src 159.65.224.174 +acl SSL_ports port 443 +acl Safe_ports port 80 # http +acl Safe_ports port 21 # ftp +acl Safe_ports port 443 # https +acl Safe_ports port 70 # gopher +acl Safe_ports port 210 # wais +acl Safe_ports port 1025-65535 # unregistered ports +acl Safe_ports port 280 # http-mgmt +acl Safe_ports port 488 # gss-http +acl Safe_ports port 591 # filemaker +acl Safe_ports port 777 # multiling http +acl CONNECT method CONNECT + +http_access deny !Safe_ports +http_access deny CONNECT !SSL_ports +http_access allow localhost manager +http_access deny manager +http_access allow localhost +http_access allow localnet +http_access deny all +http_port 3128 +coredump_dir /var/spool/squid +refresh_pattern ^ftp: 1440 20% 10080 +refresh_pattern ^gopher: 1440 0% 1440 +refresh_pattern -i (/cgi-bin/|\?) 0 0% 0 +refresh_pattern \/(Packages|Sources)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims +refresh_pattern \/Release(|\.gpg)$ 0 0% 0 refresh-ims +refresh_pattern \/InRelease$ 0 0% 0 refresh-ims +refresh_pattern \/(Translation-.*)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims +refresh_pattern . 0 20% 4320 +logfile_rotate 0 + diff --git a/changedetectionio/tests/proxy_list/test_multiple_proxy.py b/changedetectionio/tests/proxy_list/test_multiple_proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..cc0da45f4b7910cf9e5b9da1dafb0fd035ffec85 --- /dev/null +++ b/changedetectionio/tests/proxy_list/test_multiple_proxy.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +import os +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks + + +def test_preferred_proxy(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + url = "http://chosen.changedetection.io" + + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + assert b"Watch added in Paused state, saving will unpause" in res.data + + wait_for_all_checks(client) + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), + data={ + "include_filters": "", + "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', + "headers": "", + "proxy": "proxy-two", + "tags": "", + "url": url, + }, + follow_redirects=True + ) + assert b"unpaused" in res.data + wait_for_all_checks(client) + # Now the request should appear in the second-squid logs diff --git a/changedetectionio/tests/proxy_list/test_noproxy.py b/changedetectionio/tests/proxy_list/test_noproxy.py new file mode 100644 index 0000000000000000000000000000000000000000..fdd9aa354210c5e5512f4d7094c3d8c6694e5723 --- /dev/null +++ b/changedetectionio/tests/proxy_list/test_noproxy.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client + + +def test_noproxy_option(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + # Run by run_proxy_tests.sh + # Call this URL then scan the containers that it never went through them + url = "http://noproxy.changedetection.io" + + # Should only be available when a proxy is setup + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1)) + assert b'No proxy' not in res.data + + # Setup a proxy + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-ignore_whitespace": "y", + "application-fetch_backend": "html_requests", + "requests-extra_proxies-0-proxy_name": "custom-one-proxy", + "requests-extra_proxies-0-proxy_url": "http://test:awesome@squid-one:3128", + "requests-extra_proxies-1-proxy_name": "custom-two-proxy", + "requests-extra_proxies-1-proxy_url": "http://test:awesome@squid-two:3128", + "requests-extra_proxies-2-proxy_name": "custom-proxy", + "requests-extra_proxies-2-proxy_url": "http://test:awesome@squid-custom:3128", + }, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + + # Should be available as an option + res = client.get( + url_for("settings.settings_page", unpause_on_save=1)) + assert b'No proxy' in res.data + + + # This will add it paused + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + assert b"Watch added in Paused state, saving will unpause" in res.data + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + res = client.get( + url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1)) + assert b'No proxy' in res.data + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1), + data={ + "include_filters": "", + "fetch_backend": "html_requests", + "headers": "", + "proxy": "no-proxy", + "tags": "", + "url": url, + }, + follow_redirects=True + ) + assert b"unpaused" in res.data + wait_for_all_checks(client) + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + # Now the request should NOT appear in the second-squid logs (handled by the run_test_proxies.sh script) + + # Prove that it actually checked + + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] != 0 + diff --git a/changedetectionio/tests/proxy_list/test_proxy.py b/changedetectionio/tests/proxy_list/test_proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..bda17d1b6a5e132481851039f3199a80a94aeacc --- /dev/null +++ b/changedetectionio/tests/proxy_list/test_proxy.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client + +# just make a request, we will grep in the docker logs to see it actually got called +def test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + res = client.post( + url_for("imports.import_page"), + # Because a URL wont show in squid/proxy logs due it being SSLed + # Use plain HTTP or a specific domain-name here + data={"urls": "http://one.changedetection.io"}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) diff --git a/changedetectionio/tests/proxy_list/test_proxy_noconnect.py b/changedetectionio/tests/proxy_list/test_proxy_noconnect.py new file mode 100644 index 0000000000000000000000000000000000000000..31edaadb23d3011a1bf01243009113c2b966c8e0 --- /dev/null +++ b/changedetectionio/tests/proxy_list/test_proxy_noconnect.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks +import os +from ... import strtobool + + +# Just to be sure the UI outputs the right error message on proxy connection failed +# docker run -p 4444:4444 --rm --shm-size="2g" selenium/standalone-chrome:4 +# PLAYWRIGHT_DRIVER_URL=ws://127.0.0.1:3000 pytest tests/proxy_list/test_proxy_noconnect.py +# FAST_PUPPETEER_CHROME_FETCHER=True PLAYWRIGHT_DRIVER_URL=ws://127.0.0.1:3000 pytest tests/proxy_list/test_proxy_noconnect.py +# WEBDRIVER_URL=http://127.0.0.1:4444/wd/hub pytest tests/proxy_list/test_proxy_noconnect.py + +def test_proxy_noconnect_custom(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + + # Goto settings, add our custom one + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-ignore_whitespace": "y", + "application-fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') or os.getenv("WEBDRIVER_URL") else 'html_requests', + "requests-extra_proxies-0-proxy_name": "custom-test-proxy", + # test:awesome is set in tests/proxy_list/squid-passwords.txt + "requests-extra_proxies-0-proxy_url": "http://127.0.0.1:3128", + }, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + + test_url = "https://changedetection.io" + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + + assert b"Watch added in Paused state, saving will unpause" in res.data + + options = { + "url": test_url, + "fetch_backend": "html_webdriver" if os.getenv('PLAYWRIGHT_DRIVER_URL') or os.getenv("WEBDRIVER_URL") else "html_requests", + "proxy": "ui-0custom-test-proxy", + } + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), + data=options, + follow_redirects=True + ) + assert b"unpaused" in res.data + import time + wait_for_all_checks(client) + + # Requests default + check_string = b'Cannot connect to proxy' + + if os.getenv('PLAYWRIGHT_DRIVER_URL') or strtobool(os.getenv('FAST_PUPPETEER_CHROME_FETCHER', 'False')) or os.getenv("WEBDRIVER_URL"): + check_string = b'ERR_PROXY_CONNECTION_FAILED' + + + res = client.get(url_for("watchlist.index")) + #with open("/tmp/debug.html", 'wb') as f: + # f.write(res.data) + assert check_string in res.data diff --git a/changedetectionio/tests/proxy_list/test_select_custom_proxy.py b/changedetectionio/tests/proxy_list/test_select_custom_proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..ab831358754277193f9dca212b6f4d0b42a2d3f4 --- /dev/null +++ b/changedetectionio/tests/proxy_list/test_select_custom_proxy.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks +import os + +# just make a request, we will grep in the docker logs to see it actually got called +def test_select_custom(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + + # Goto settings, add our custom one + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-ignore_whitespace": "y", + "application-fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', + "requests-extra_proxies-0-proxy_name": "custom-test-proxy", + # test:awesome is set in tests/proxy_list/squid-passwords.txt + "requests-extra_proxies-0-proxy_url": "http://test:awesome@squid-custom:3128", + }, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + + res = client.post( + url_for("imports.import_page"), + # Because a URL wont show in squid/proxy logs due it being SSLed + # Use plain HTTP or a specific domain-name here + data={"urls": "https://changedetection.io/CHANGELOG.txt"}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + assert b'Proxy Authentication Required' not in res.data + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + # We should see something via proxy + assert b' - 0.' in res.data + + # + # Now we should see the request in the container logs for "squid-squid-custom" because it will be the only default + diff --git a/changedetectionio/tests/proxy_socks5/proxies.json-example b/changedetectionio/tests/proxy_socks5/proxies.json-example new file mode 100644 index 0000000000000000000000000000000000000000..e70635484b1102445c1d380e3949d08ebb5e752e --- /dev/null +++ b/changedetectionio/tests/proxy_socks5/proxies.json-example @@ -0,0 +1,6 @@ +{ + "socks5proxy": { + "label": "socks5proxy", + "url": "socks5://proxy_user123:proxy_pass123@socks5proxy:1080" + } +} diff --git a/changedetectionio/tests/proxy_socks5/proxies.json-example-noauth b/changedetectionio/tests/proxy_socks5/proxies.json-example-noauth new file mode 100644 index 0000000000000000000000000000000000000000..e63d05370357db29cdffd0b02e2517fa668cab52 --- /dev/null +++ b/changedetectionio/tests/proxy_socks5/proxies.json-example-noauth @@ -0,0 +1,6 @@ +{ + "socks5proxy": { + "label": "socks5proxy", + "url": "socks5://socks5proxy-noauth:1080" + } +} diff --git a/changedetectionio/tests/proxy_socks5/test_socks5_proxy.py b/changedetectionio/tests/proxy_socks5/test_socks5_proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..f2595bd013ae14a0f6d7f3ee8cfdde236567931a --- /dev/null +++ b/changedetectionio/tests/proxy_socks5/test_socks5_proxy.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +import json +import os +from flask import url_for +from changedetectionio.tests.util import live_server_setup, wait_for_all_checks, extract_UUID_from_client + + +def set_response(): + import time + data = """ + +

    Awesome, you made it

    + yeah the socks request worked + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(data) + time.sleep(1) + +def test_socks5(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + set_response() + + # Setup a proxy + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-ignore_whitespace": "y", + "application-fetch_backend": "html_requests", + # set in .github/workflows/test-only.yml + "requests-extra_proxies-0-proxy_url": "socks5://proxy_user123:proxy_pass123@socks5proxy:1080", + "requests-extra_proxies-0-proxy_name": "socks5proxy", + }, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + + # Because the socks server should connect back to us + test_url = url_for('test_endpoint', _external=True) + f"?socks-test-tag={os.getenv('SOCKSTEST', '')}" + test_url = test_url.replace('localhost.localdomain', 'cdio') + test_url = test_url.replace('localhost', 'cdio') + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + assert b"Watch added in Paused state, saving will unpause" in res.data + + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), + ) + # check the proxy is offered as expected + assert b'ui-0socks5proxy' in res.data + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), + data={ + "include_filters": "", + "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', + "headers": "", + "proxy": "ui-0socks5proxy", + "tags": "", + "url": test_url, + }, + follow_redirects=True + ) + assert b"unpaused" in res.data + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + # Should see the proper string + assert "Awesome, you made it".encode('utf-8') in res.data + + # PROXY CHECKER WIDGET CHECK - this needs more checking + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + + res = client.get( + url_for("check_proxies.start_check", uuid=uuid), + follow_redirects=True + ) + # It's probably already finished super fast :( + #assert b"RUNNING" in res.data + + wait_for_all_checks(client) + res = client.get( + url_for("check_proxies.get_recheck_status", uuid=uuid), + follow_redirects=True + ) + assert b"OK" in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + diff --git a/changedetectionio/tests/proxy_socks5/test_socks5_proxy_sources.py b/changedetectionio/tests/proxy_socks5/test_socks5_proxy_sources.py new file mode 100644 index 0000000000000000000000000000000000000000..3805c88d1287220c16aa93706285b4b6dfc9ed0f --- /dev/null +++ b/changedetectionio/tests/proxy_socks5/test_socks5_proxy_sources.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +import os +from flask import url_for +from changedetectionio.tests.util import live_server_setup, wait_for_all_checks + + +def set_response(): + import time + data = """ + +

    Awesome, you made it

    + yeah the socks request worked + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(data) + time.sleep(1) + +# should be proxies.json mounted from run_proxy_tests.sh already +# -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json +def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + set_response() + # Because the socks server should connect back to us + test_url = url_for('test_endpoint', _external=True) + f"?socks-test-tag={os.getenv('SOCKSTEST', '')}" + test_url = test_url.replace('localhost.localdomain', 'cdio') + test_url = test_url.replace('localhost', 'cdio') + + res = client.get(url_for("settings.settings_page")) + assert b'name="requests-proxy" type="radio" value="socks5proxy"' in res.data + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + assert b"Watch added in Paused state, saving will unpause" in res.data + + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), + ) + # check the proxy is offered as expected + assert b'name="proxy" type="radio" value="socks5proxy"' in res.data + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), + data={ + "include_filters": "", + "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', + "headers": "", + "proxy": "socks5proxy", + "tags": "", + "url": test_url, + }, + follow_redirects=True + ) + assert b"unpaused" in res.data + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + # Should see the proper string + assert "Awesome, you made it".encode('utf-8') in res.data diff --git a/changedetectionio/tests/realtime/test_socketio.py b/changedetectionio/tests/realtime/test_socketio.py new file mode 100644 index 0000000000000000000000000000000000000000..68c07793c254e29fa56006d60fd5d316b624168c --- /dev/null +++ b/changedetectionio/tests/realtime/test_socketio.py @@ -0,0 +1,72 @@ +import asyncio +import socketio +from aiohttp import web + +SOCKETIO_URL = 'ws://localhost.localdomain:5005' +SOCKETIO_PATH = "/socket.io" +NUM_CLIENTS = 1 + +clients = [] +shutdown_event = asyncio.Event() + +class WatchClient: + def __init__(self, client_id: int): + self.client_id = client_id + self.i_got_watch_update_event = False + self.sio = socketio.AsyncClient(reconnection_attempts=50, reconnection_delay=1) + + @self.sio.event + async def connect(): + print(f"[Client {self.client_id}] Connected") + + @self.sio.event + async def disconnect(): + print(f"[Client {self.client_id}] Disconnected") + + @self.sio.on("watch_update") + async def on_watch_update(watch): + self.i_got_watch_update_event = True + print(f"[Client {self.client_id}] Received update: {watch}") + + async def run(self): + try: + await self.sio.connect(SOCKETIO_URL, socketio_path=SOCKETIO_PATH, transports=["websocket", "polling"]) + await self.sio.wait() + except Exception as e: + print(f"[Client {self.client_id}] Connection error: {e}") + +async def handle_check(request): + all_received = all(c.i_got_watch_update_event for c in clients) + result = "yes" if all_received else "no" + print(f"Received HTTP check — returning '{result}'") + shutdown_event.set() # Signal shutdown + return web.Response(text=result) + +async def start_http_server(): + app = web.Application() + app.add_routes([web.get('/did_all_clients_get_watch_update', handle_check)]) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '0.0.0.0', 6666) + await site.start() + +async def main(): + #await start_http_server() + + for i in range(NUM_CLIENTS): + client = WatchClient(i) + clients.append(client) + asyncio.create_task(client.run()) + + await shutdown_event.wait() + + print("Shutting down...") + # Graceful disconnect + for c in clients: + await c.sio.disconnect() + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("Interrupted") diff --git a/changedetectionio/tests/restock/__init__.py b/changedetectionio/tests/restock/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..085b3d78caa449246fa27bc8ebf3559ed6380577 --- /dev/null +++ b/changedetectionio/tests/restock/__init__.py @@ -0,0 +1,2 @@ +"""Tests for the app.""" + diff --git a/changedetectionio/tests/restock/conftest.py b/changedetectionio/tests/restock/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..9a4bf56bccae80b022fe170097d9accefd8f9f1f --- /dev/null +++ b/changedetectionio/tests/restock/conftest.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 + +from .. import conftest diff --git a/changedetectionio/tests/restock/test_restock.py b/changedetectionio/tests/restock/test_restock.py new file mode 100644 index 0000000000000000000000000000000000000000..ecee00fabc3074f92c7271d93305a748168a650d --- /dev/null +++ b/changedetectionio/tests/restock/test_restock.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +import os +import time +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client, wait_for_notification_endpoint_output +from changedetectionio.notification import ( + default_notification_body, + default_notification_format, + default_notification_title, + valid_notification_formats, +) + + +def set_original_response(): + test_return_data = """ + + + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    price: $10.99
    +
    Out of stock
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + + + +def set_back_in_stock_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    price: $10.99
    +
    Available!
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + +# Add a site in paused mode, add an invalid filter, we should still have visual selector data ready +def test_restock_detection(client, live_server, measure_memory_usage): + + set_original_response() + #assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" + # live_server_setup(live_server) # Setup on conftest per function + ##################### + notification_url = url_for('test_notification_endpoint', _external=True).replace('http://localhost', 'http://changedet').replace('http', 'json') + + + ##################### + # Set this up for when we remove the notification from the watch, it should fallback with these details + res = client.post( + url_for("settings.settings_page"), + data={"application-notification_urls": notification_url, + "application-notification_title": "fallback-title "+default_notification_title, + "application-notification_body": "fallback-body "+default_notification_body, + "application-notification_format": default_notification_format, + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_webdriver"}, + follow_redirects=True + ) + # Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url + test_url = url_for('test_endpoint', _external=True).replace('http://localhost', 'http://changedet') + + + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'processor': 'restock_diff'}, + follow_redirects=True + ) + + # Is it correctly show as NOT in stock? + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'processor-restock_diff' in res.data # Should have saved in restock mode + assert b'not-in-stock' in res.data # should be out of stock + + # Is it correctly shown as in stock + set_back_in_stock_response() + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'not-in-stock' not in res.data + + # We should have a notification + wait_for_notification_endpoint_output() + assert os.path.isfile("test-datastore/notification.txt"), "Notification received" + os.unlink("test-datastore/notification.txt") + + # Default behaviour is to only fire notification when it goes OUT OF STOCK -> IN STOCK + # So here there should be no file, because we go IN STOCK -> OUT OF STOCK + set_original_response() + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + time.sleep(5) + assert not os.path.isfile("test-datastore/notification.txt"), "No notification should have fired when it went OUT OF STOCK by default" + + # BUT we should see that it correctly shows "not in stock" + res = client.get(url_for("watchlist.index")) + assert b'not-in-stock' in res.data, "Correctly showing NOT IN STOCK in the list after it changed from IN STOCK" + diff --git a/changedetectionio/tests/smtp/smtp-test-server.py b/changedetectionio/tests/smtp/smtp-test-server.py new file mode 100644 index 0000000000000000000000000000000000000000..a6e3df6661123831c20088d95496cfa258fe92bc --- /dev/null +++ b/changedetectionio/tests/smtp/smtp-test-server.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import asyncio +from aiosmtpd.controller import Controller +from aiosmtpd.smtp import SMTP + +# Accept a SMTP message and offer a way to retrieve the last message via TCP Socket + +last_received_message = b"Nothing" + + +class CustomSMTPHandler: + async def handle_DATA(self, server, session, envelope): + global last_received_message + last_received_message = envelope.content + print('Receiving message from:', session.peer) + print('Message addressed from:', envelope.mail_from) + print('Message addressed to :', envelope.rcpt_tos) + print('Message length :', len(envelope.content)) + print(envelope.content.decode('utf8')) + return '250 Message accepted for delivery' + + +class EchoServerProtocol(asyncio.Protocol): + def connection_made(self, transport): + global last_received_message + self.transport = transport + peername = transport.get_extra_info('peername') + print('Incoming connection from {}'.format(peername)) + self.transport.write(last_received_message) + + last_received_message = b'' + self.transport.close() + + +async def main(): + # Start the SMTP server + controller = Controller(CustomSMTPHandler(), hostname='0.0.0.0', port=11025) + controller.start() + + # Start the TCP Echo server + loop = asyncio.get_running_loop() + server = await loop.create_server( + lambda: EchoServerProtocol(), + '0.0.0.0', 11080 + ) + async with server: + await server.serve_forever() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/changedetectionio/tests/smtp/test_notification_smtp.py b/changedetectionio/tests/smtp/test_notification_smtp.py new file mode 100644 index 0000000000000000000000000000000000000000..a400901c4cb45e7b8f8109968df579c1c9fcdb44 --- /dev/null +++ b/changedetectionio/tests/smtp/test_notification_smtp.py @@ -0,0 +1,182 @@ +import json +import os +import time +import re +from flask import url_for +from changedetectionio.tests.util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, \ + wait_for_all_checks, \ + set_longer_modified_response +from changedetectionio.tests.util import extract_UUID_from_client +import logging +import base64 + +# NOTE - RELIES ON mailserver as hostname running, see github build recipes +smtp_test_server = 'mailserver' + +from changedetectionio.notification import ( + default_notification_body, + default_notification_format, + default_notification_title, + valid_notification_formats, +) + + + +def get_last_message_from_smtp_server(): + import socket + port = 11080 # socket server port number + + client_socket = socket.socket() # instantiate + client_socket.connect((smtp_test_server, port)) # connect to the server + + data = client_socket.recv(50024).decode() # receive response + logging.info("get_last_message_from_smtp_server..") + logging.info(data) + client_socket.close() # close the connection + return data + + +# Requires running the test SMTP server + +def test_check_notification_email_formats_default_HTML(client, live_server, measure_memory_usage): + ## live_server_setup(live_server) # Setup on conftest per function + set_original_response() + + notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com' + + ##################### + # Set this up for when we remove the notification from the watch, it should fallback with these details + res = client.post( + url_for("settings.settings_page"), + data={"application-notification_urls": notification_url, + "application-notification_title": "fallback-title " + default_notification_title, + "application-notification_body": "fallback-body
    " + default_notification_body, + "application-notification_format": 'HTML', + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Settings updated." in res.data + + # Add a watch and trigger a HTTP POST + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'nice one'}, + follow_redirects=True + ) + + assert b"Watch added" in res.data + + wait_for_all_checks(client) + set_longer_modified_response() + time.sleep(2) + + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + time.sleep(3) + + msg = get_last_message_from_smtp_server() + assert len(msg) >= 1 + + # The email should have two bodies, and the text/html part should be
    + assert 'Content-Type: text/plain' in msg + assert '(added) So let\'s see what happens.\r\n' in msg # The plaintext part with \r\n + assert 'Content-Type: text/html' in msg + assert '(added) So let\'s see what happens.
    ' in msg # the html part + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_check_notification_email_formats_default_Text_override_HTML(client, live_server, measure_memory_usage): + ## live_server_setup(live_server) # Setup on conftest per function + + # HTML problems? see this + # https://github.com/caronc/apprise/issues/633 + + set_original_response() + notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com' + notification_body = f""" + + + My Webpage + + +

    Test

    + {default_notification_body} + + +""" + + ##################### + # Set this up for when we remove the notification from the watch, it should fallback with these details + res = client.post( + url_for("settings.settings_page"), + data={"application-notification_urls": notification_url, + "application-notification_title": "fallback-title " + default_notification_title, + "application-notification_body": notification_body, + "application-notification_format": 'Text', + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Settings updated." in res.data + + # Add a watch and trigger a HTTP POST + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'nice one'}, + follow_redirects=True + ) + + assert b"Watch added" in res.data + + wait_for_all_checks(client) + set_longer_modified_response() + time.sleep(2) + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + time.sleep(3) + msg = get_last_message_from_smtp_server() + assert len(msg) >= 1 + # with open('/tmp/m.txt', 'w') as f: + # f.write(msg) + + # The email should not have two bodies, should be TEXT only + + assert 'Content-Type: text/plain' in msg + assert '(added) So let\'s see what happens.\r\n' in msg # The plaintext part with \r\n + + set_original_response() + # Now override as HTML format + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "url": test_url, + "notification_format": 'HTML', + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + time.sleep(3) + msg = get_last_message_from_smtp_server() + assert len(msg) >= 1 + + # The email should have two bodies, and the text/html part should be
    + assert 'Content-Type: text/plain' in msg + assert '(removed) So let\'s see what happens.\r\n' in msg # The plaintext part with \n + assert 'Content-Type: text/html' in msg + assert '(removed) So let\'s see what happens.
    ' in msg # the html part + + # https://github.com/dgtlmoon/changedetection.io/issues/2103 + assert '

    Test

    ' in msg + assert '<' not in msg + assert 'Content-Type: text/html' in msg + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test.pdf b/changedetectionio/tests/test.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d4e527abe7223510d2a657eb10b2382363733a10 Binary files /dev/null and b/changedetectionio/tests/test.pdf differ diff --git a/changedetectionio/tests/test2.pdf b/changedetectionio/tests/test2.pdf new file mode 100644 index 0000000000000000000000000000000000000000..083ad8ad81e3e6097156f63bdeba4d7e58961901 Binary files /dev/null and b/changedetectionio/tests/test2.pdf differ diff --git a/changedetectionio/tests/test_access_control.py b/changedetectionio/tests/test_access_control.py new file mode 100644 index 0000000000000000000000000000000000000000..a72e64a9a0ae41f1c900ac126912e8ac4a9e3bee --- /dev/null +++ b/changedetectionio/tests/test_access_control.py @@ -0,0 +1,190 @@ +from .util import live_server_setup, wait_for_all_checks +from flask import url_for +import time + +def test_check_access_control(app, client, live_server): + # Still doesnt work, but this is closer. + # live_server_setup(live_server) # Setup on conftest per function + + with app.test_client(use_cookies=True) as c: + # Check we don't have any password protection enabled yet. + res = c.get(url_for("settings.settings_page")) + assert b"Remove password" not in res.data + + # add something that we can hit via diff page later + res = c.post( + url_for("imports.import_page"), + data={"urls": url_for('test_random_content_endpoint', _external=True)}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + time.sleep(3) + # causes a 'Popped wrong request context.' error when client. is accessed? + #wait_for_all_checks(client) + + res = c.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + time.sleep(3) + # causes a 'Popped wrong request context.' error when client. is accessed? + #wait_for_all_checks(client) + + + # Enable password check and diff page access bypass + res = c.post( + url_for("settings.settings_page"), + data={"application-password": "foobar", + "application-shared_diff_access": "True", + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Password protection enabled." in res.data + + # Check we hit the login + res = c.get(url_for("watchlist.index"), follow_redirects=True) + # Should be logged out + assert b"Login" in res.data + + # The diff page should return something valid when logged out + res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first")) + assert b'Random content' in res.data + + # access to assets should work (check_authentication) + res = c.get(url_for('static_content', group='js', filename='jquery-3.6.0.min.js')) + assert res.status_code == 200 + res = c.get(url_for('static_content', group='styles', filename='styles.css')) + assert res.status_code == 200 + res = c.get(url_for('static_content', group='styles', filename='404-testetest.css')) + assert res.status_code == 404 + + # Access to screenshots should be limited by 'shared_diff_access' + path = url_for('static_content', group='screenshot', filename='random-uuid-that-will-404.png', _external=True) + res = c.get(path) + assert res.status_code == 404 + + # Check wrong password does not let us in + res = c.post( + url_for("login"), + data={"password": "WRONG PASSWORD"}, + follow_redirects=True + ) + + assert b"LOG OUT" not in res.data + assert b"Incorrect password" in res.data + + + # Menu should not be available yet + # assert b"SETTINGS" not in res.data + # assert b"BACKUP" not in res.data + # assert b"IMPORT" not in res.data + + # defaultuser@changedetection.io is actually hardcoded for now, we only use a single password + res = c.post( + url_for("login"), + data={"password": "foobar"}, + follow_redirects=True + ) + + # Yes we are correctly logged in + assert b"LOG OUT" in res.data + + # 598 - Password should be set and not accidently removed + res = c.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + + res = c.get(url_for("logout"), + follow_redirects=True) + + assert b"Login" in res.data + + res = c.get(url_for("settings.settings_page"), + follow_redirects=True) + + + assert b"Login" in res.data + + res = c.get(url_for("login")) + assert b"Login" in res.data + + + res = c.post( + url_for("login"), + data={"password": "foobar"}, + follow_redirects=True + ) + + # Yes we are correctly logged in + assert b"LOG OUT" in res.data + + res = c.get(url_for("settings.settings_page")) + + # Menu should be available now + assert b"SETTINGS" in res.data + assert b"BACKUP" in res.data + assert b"IMPORT" in res.data + assert b"LOG OUT" in res.data + assert b"time_between_check-minutes" in res.data + assert b"fetch_backend" in res.data + + ################################################## + # Remove password button, and check that it worked + ################################################## + res = c.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-fetch_backend": "html_webdriver", + "application-removepassword_button": "Remove password" + }, + follow_redirects=True, + ) + assert b"Password protection removed." in res.data + assert b"LOG OUT" not in res.data + + ############################################################ + # Be sure a blank password doesnt setup password protection + ############################################################ + res = c.post( + url_for("settings.settings_page"), + data={"application-password": "", + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Password protection enabled" not in res.data + + # Now checking the diff access + # Enable password check and diff page access bypass + res = c.post( + url_for("settings.settings_page"), + data={"application-password": "foobar", + # Should be disabled + "application-shared_diff_access": "", + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Password protection enabled." in res.data + + # Check we hit the login + res = c.get(url_for("watchlist.index"), follow_redirects=True) + # Should be logged out + assert b"Login" in res.data + + # Access to screenshots should be limited by 'shared_diff_access' + res = c.get(url_for('static_content', group='screenshot', filename='random-uuid-that-will-403.png')) + assert res.status_code == 403 + + # The diff page should return something valid when logged out + res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first")) + assert b'Random content' not in res.data diff --git a/changedetectionio/tests/test_add_replace_remove_filter.py b/changedetectionio/tests/test_add_replace_remove_filter.py new file mode 100644 index 0000000000000000000000000000000000000000..3ca5284e47fdb699904f416fa0d9f3c541e3f4db --- /dev/null +++ b/changedetectionio/tests/test_add_replace_remove_filter.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 + +import os.path + +from flask import url_for +from .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output +import time + +def set_original(excluding=None, add_line=None): + test_return_data = """ + +

    Some initial text

    +

    So let's see what happens.

    +

    and a new line!

    +

    The golden line

    +

    A BREAK TO MAKE THE TOP LINE STAY AS "REMOVED" OR IT WILL GET COUNTED AS "CHANGED INTO"

    +

    Something irrelevant

    + + + """ + + if add_line: + c=test_return_data.splitlines() + c.insert(5, add_line) + test_return_data = "\n".join(c) + + if excluding: + output = "" + for i in test_return_data.splitlines(): + if not excluding in i: + output += f"{i}\n" + + test_return_data = output + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + +# def test_setup(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + +def test_check_removed_line_contains_trigger(client, live_server, measure_memory_usage): + + # Give the endpoint time to spin up + set_original() + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"trigger_text": 'The golden line', + "url": test_url, + 'fetch_backend': "html_requests", + 'filter_text_removed': 'y'}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + set_original(excluding='Something irrelevant') + + # A line thats not the trigger should not trigger anything + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + wait_for_all_checks(client) + time.sleep(0.5) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + # The trigger line is REMOVED, this should trigger + set_original(excluding='The golden line') + + # Check in the processor here what's going on, its triggering empty-reply and no change. + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + time.sleep(1) + + # Now add it back, and we should not get a trigger + client.get(url_for("ui.mark_all_viewed"), follow_redirects=True) + time.sleep(0.2) + + time.sleep(1) + set_original(excluding=None) + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + time.sleep(1) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + # Remove it again, and we should get a trigger + set_original(excluding='The golden line') + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_check_add_line_contains_trigger(client, live_server, measure_memory_usage): + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + time.sleep(1) + + # Give the endpoint time to spin up + test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}" + + res = client.post( + url_for("settings.settings_page"), + data={"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", + # triggered_text will contain multiple lines + "application-notification_body": 'triggered text was -{{triggered_text}}- ### 网站监测 内容更新了 ####', + # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation + "application-notification_urls": test_notification_url, + "application-minutes_between_check": 180, + "application-fetch_backend": "html_requests" + }, + follow_redirects=True + ) + assert b'Settings updated' in res.data + + set_original() + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"trigger_text": 'Oh yes please', + "url": test_url, + 'processor': 'text_json_diff', + 'fetch_backend': "html_requests", + 'filter_text_removed': '', + 'filter_text_added': 'y'}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + set_original(excluding='Something irrelevant') + + # A line thats not the trigger should not trigger anything + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + # The trigger line is ADDED, this should trigger + set_original(add_line='

    Oh yes please

    ') + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + + assert b'unviewed' in res.data + + # Takes a moment for apprise to fire + wait_for_notification_endpoint_output() + assert os.path.isfile("test-datastore/notification.txt"), "Notification fired because I can see the output file" + with open("test-datastore/notification.txt", 'rb') as f: + response = f.read() + assert b'-Oh yes please' in response + assert '网站监测 内容更新了'.encode('utf-8') in response + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test_api.py b/changedetectionio/tests/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..2cd87e5bc159d96bfaf6e30d0906f7cb3c999361 --- /dev/null +++ b/changedetectionio/tests/test_api.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + +import json +import uuid + + +def set_original_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text thats the same
    +
    Some text that will change
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + + +def set_modified_response(): + test_return_data = """ + + Some initial text
    +

    which has this one new line

    +
    + So let's see what happens.
    +
    Some text thats the same
    +
    Some text that changes
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + return None + +def is_valid_uuid(val): + try: + uuid.UUID(str(val)) + return True + except ValueError: + return False + + +# def test_setup(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + + +def test_api_simple(client, live_server, measure_memory_usage): + + + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + + # Create a watch + set_original_response() + + # Validate bad URL + test_url = url_for('test_endpoint', _external=True ) + res = client.post( + url_for("createwatch"), + data=json.dumps({"url": "h://xxxxxxxxxom"}), + headers={'content-type': 'application/json', 'x-api-key': api_key}, + follow_redirects=True + ) + assert res.status_code == 400 + + # Create new + res = client.post( + url_for("createwatch"), + data=json.dumps({"url": test_url, 'tag': "One, Two", "title": "My test URL"}), + headers={'content-type': 'application/json', 'x-api-key': api_key}, + follow_redirects=True + ) + + assert is_valid_uuid(res.json.get('uuid')) + watch_uuid = res.json.get('uuid') + assert res.status_code == 201 + + wait_for_all_checks(client) + + # Verify its in the list and that recheck worked + res = client.get( + url_for("createwatch", tag="OnE"), + headers={'x-api-key': api_key} + ) + assert watch_uuid in res.json.keys() + before_recheck_info = res.json[watch_uuid] + + assert before_recheck_info['last_checked'] != 0 + + #705 `last_changed` should be zero on the first check + assert before_recheck_info['last_changed'] == 0 + assert before_recheck_info['title'] == 'My test URL' + + # Check the limit by tag doesnt return anything when nothing found + res = client.get( + url_for("createwatch", tag="Something else entirely"), + headers={'x-api-key': api_key} + ) + assert len(res.json) == 0 + time.sleep(1) + wait_for_all_checks(client) + + set_modified_response() + # Trigger recheck of all ?recheck_all=1 + client.get( + url_for("createwatch", recheck_all='1'), + headers={'x-api-key': api_key}, + ) + wait_for_all_checks(client) + + time.sleep(1) + # Did the recheck fire? + res = client.get( + url_for("createwatch"), + headers={'x-api-key': api_key}, + ) + after_recheck_info = res.json[watch_uuid] + assert after_recheck_info['last_checked'] != before_recheck_info['last_checked'] + assert after_recheck_info['last_changed'] != 0 + + # #2877 When run in a slow fetcher like playwright etc + assert after_recheck_info['last_changed'] == after_recheck_info['last_checked'] + + # Check history index list + res = client.get( + url_for("watchhistory", uuid=watch_uuid), + headers={'x-api-key': api_key}, + ) + assert len(res.json) == 2, "Should have two history entries (the original and the changed)" + + # Fetch a snapshot by timestamp, check the right one was found + res = client.get( + url_for("watchsinglehistory", uuid=watch_uuid, timestamp=list(res.json.keys())[-1]), + headers={'x-api-key': api_key}, + ) + assert b'which has this one new line' in res.data + + # Fetch a snapshot by 'latest'', check the right one was found + res = client.get( + url_for("watchsinglehistory", uuid=watch_uuid, timestamp='latest'), + headers={'x-api-key': api_key}, + ) + assert b'which has this one new line' in res.data + assert b'
    0.5 + + ###################################################### + # Mute and Pause, check it worked + res = client.get( + url_for("watch", uuid=watch_uuid, paused='paused'), + headers={'x-api-key': api_key} + ) + assert b'OK' in res.data + res = client.get( + url_for("watch", uuid=watch_uuid, muted='muted'), + headers={'x-api-key': api_key} + ) + assert b'OK' in res.data + res = client.get( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key} + ) + assert res.json.get('paused') == True + assert res.json.get('notification_muted') == True + + # Now unpause, unmute + res = client.get( + url_for("watch", uuid=watch_uuid, muted='unmuted'), + headers={'x-api-key': api_key} + ) + assert b'OK' in res.data + res = client.get( + url_for("watch", uuid=watch_uuid, paused='unpaused'), + headers={'x-api-key': api_key} + ) + assert b'OK' in res.data + res = client.get( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key} + ) + assert res.json.get('paused') == 0 + assert res.json.get('notification_muted') == 0 + ###################################################### + + # Finally delete the watch + res = client.delete( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key}, + ) + assert res.status_code == 204 + + # Check via a relist + res = client.get( + url_for("createwatch"), + headers={'x-api-key': api_key} + ) + assert len(res.json) == 0, "Watch list should be empty" + +def test_access_denied(client, live_server, measure_memory_usage): + # `config_api_token_enabled` Should be On by default + res = client.get( + url_for("createwatch") + ) + assert res.status_code == 403 + + res = client.get( + url_for("createwatch"), + headers={'x-api-key': "something horrible"} + ) + assert res.status_code == 403 + + # Disable config_api_token_enabled and it should work + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-fetch_backend": "html_requests", + "application-api_access_token_enabled": "" + }, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + + res = client.get( + url_for("createwatch") + ) + assert res.status_code == 200 + + # Cleanup everything + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-fetch_backend": "html_requests", + "application-api_access_token_enabled": "y" + }, + follow_redirects=True + ) + assert b"Settings updated." in res.data + +def test_api_watch_PUT_update(client, live_server, measure_memory_usage): + + + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + + # Create a watch + set_original_response() + test_url = url_for('test_endpoint', _external=True) + + # Create new + res = client.post( + url_for("createwatch"), + data=json.dumps({"url": test_url, 'tag': "One, Two", "title": "My test URL", 'headers': {'cookie': 'yum'} }), + headers={'content-type': 'application/json', 'x-api-key': api_key}, + follow_redirects=True + ) + + assert res.status_code == 201 + + + # Get a listing, it will be the first one + res = client.get( + url_for("createwatch"), + headers={'x-api-key': api_key} + ) + + watch_uuid = list(res.json.keys())[0] + + # Check in the edit page just to be sure + res = client.get( + url_for("ui.ui_edit.edit_page", uuid=watch_uuid), + ) + assert b"cookie: yum" in res.data, "'cookie: yum' found in 'headers' section" + assert b"One" in res.data, "Tag 'One' was found" + assert b"Two" in res.data, "Tag 'Two' was found" + + # HTTP PUT ( UPDATE an existing watch ) + res = client.put( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key, 'content-type': 'application/json'}, + data=json.dumps({"title": "new title", 'time_between_check': {'minutes': 552}, 'headers': {'cookie': 'all eaten'}}), + ) + assert res.status_code == 200, "HTTP PUT update was sent OK" + + # HTTP GET single watch, title should be updated + res = client.get( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key} + ) + assert res.json.get('title') == 'new title' + + # Check in the edit page just to be sure + res = client.get( + url_for("ui.ui_edit.edit_page", uuid=watch_uuid), + ) + assert b"new title" in res.data, "new title found in edit page" + assert b"552" in res.data, "552 minutes found in edit page" + assert b"One" in res.data, "Tag 'One' was found" + assert b"Two" in res.data, "Tag 'Two' was found" + assert b"cookie: all eaten" in res.data, "'cookie: all eaten' found in 'headers' section" + + ###################################################### + + # HTTP PUT try a field that doenst exist + + # HTTP PUT an update + res = client.put( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key, 'content-type': 'application/json'}, + data=json.dumps({"title": "new title", "some other field": "uh oh"}), + ) + + assert res.status_code == 400, "Should get error 400 when we give a field that doesnt exist" + # Message will come from `flask_expects_json` + assert b'Additional properties are not allowed' in res.data + + # Cleanup everything + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_api_import(client, live_server, measure_memory_usage): + + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + + res = client.post( + url_for("import") + "?tag=import-test", + data='https://website1.com\r\nhttps://website2.com', + headers={'x-api-key': api_key}, + follow_redirects=True + ) + + assert res.status_code == 200 + assert len(res.json) == 2 + res = client.get(url_for("watchlist.index")) + assert b"https://website1.com" in res.data + assert b"https://website2.com" in res.data + + # Should see the new tag in the tag/groups list + res = client.get(url_for('tags.tags_overview_page')) + assert b'import-test' in res.data + +def test_api_conflict_UI_password(client, live_server, measure_memory_usage): + + + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + + # Enable password check and diff page access bypass + res = client.post( + url_for("settings.settings_page"), + data={"application-password": "foobar", # password is now set! API should still work! + "application-api_access_token_enabled": "y", + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Password protection enabled." in res.data + + # Create a watch + set_original_response() + test_url = url_for('test_endpoint', _external=True) + + # Create new + res = client.post( + url_for("createwatch"), + data=json.dumps({"url": test_url, "title": "My test URL" }), + headers={'content-type': 'application/json', 'x-api-key': api_key}, + follow_redirects=True + ) + + assert res.status_code == 201 + + + wait_for_all_checks(client) + url = url_for("createwatch") + # Get a listing, it will be the first one + res = client.get( + url, + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + + assert len(res.json) + + diff --git a/changedetectionio/tests/test_api_notifications.py b/changedetectionio/tests/test_api_notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..d8bad0aa93c665d47d612263b9a7dede43a63dbb --- /dev/null +++ b/changedetectionio/tests/test_api_notifications.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 + +from flask import url_for +from .util import live_server_setup +import json + +def test_api_notifications_crud(client, live_server): + # live_server_setup(live_server) # Setup on conftest per function + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + + # Confirm notifications are initially empty + res = client.get( + url_for("notifications"), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json == {"notification_urls": []} + + # Add notification URLs + test_urls = ["posts://example.com/notify1", "posts://example.com/notify2"] + res = client.post( + url_for("notifications"), + data=json.dumps({"notification_urls": test_urls}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 201 + for url in test_urls: + assert url in res.json["notification_urls"] + + # Confirm the notification URLs were added + res = client.get( + url_for("notifications"), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + for url in test_urls: + assert url in res.json["notification_urls"] + + # Delete one notification URL + res = client.delete( + url_for("notifications"), + data=json.dumps({"notification_urls": [test_urls[0]]}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 204 + + # Confirm it was removed and the other remains + res = client.get( + url_for("notifications"), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert test_urls[0] not in res.json["notification_urls"] + assert test_urls[1] in res.json["notification_urls"] + + # Try deleting a non-existent URL + res = client.delete( + url_for("notifications"), + data=json.dumps({"notification_urls": ["posts://nonexistent.com"]}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 400 + + res = client.post( + url_for("notifications"), + data=json.dumps({"notification_urls": test_urls}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 201 + + # Replace with a new list + replacement_urls = ["posts://new.example.com"] + res = client.put( + url_for("notifications"), + data=json.dumps({"notification_urls": replacement_urls}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json["notification_urls"] == replacement_urls + + # Replace with an empty list + res = client.put( + url_for("notifications"), + data=json.dumps({"notification_urls": []}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json["notification_urls"] == [] + + # Provide an invalid AppRise URL to trigger validation error + invalid_urls = ["ftp://not-app-rise"] + res = client.post( + url_for("notifications"), + data=json.dumps({"notification_urls": invalid_urls}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 400 + assert "is not a valid AppRise URL." in res.data.decode() + + res = client.put( + url_for("notifications"), + data=json.dumps({"notification_urls": invalid_urls}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 400 + assert "is not a valid AppRise URL." in res.data.decode() + + \ No newline at end of file diff --git a/changedetectionio/tests/test_api_search.py b/changedetectionio/tests/test_api_search.py new file mode 100644 index 0000000000000000000000000000000000000000..7f7dd6a389e5159335b9203c76f1f835e7c8bb18 --- /dev/null +++ b/changedetectionio/tests/test_api_search.py @@ -0,0 +1,101 @@ +from copy import copy + +from flask import url_for +import json +import time +from .util import live_server_setup, wait_for_all_checks + + +def test_api_search(client, live_server): + # live_server_setup(live_server) # Setup on conftest per function + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + + watch_data = {} + # Add some test watches + urls = [ + 'https://example.com/page1', + 'https://example.org/testing', + 'https://test-site.com/example' + ] + + # Import the test URLs + res = client.post( + url_for("imports.import_page"), + data={"urls": "\r\n".join(urls)}, + follow_redirects=True + ) + assert b"3 Imported" in res.data + wait_for_all_checks(client) + + # Get a listing, it will be the first one + watches_response = client.get( + url_for("createwatch"), + headers={'x-api-key': api_key} + ) + + + # Add a title to one watch for title search testing + for uuid, watch in watches_response.json.items(): + + watch_data = client.get(url_for("watch", uuid=uuid), + follow_redirects=True, + headers={'x-api-key': api_key} + ) + + if urls[0] == watch_data.json['url']: + # HTTP PUT ( UPDATE an existing watch ) + client.put( + url_for("watch", uuid=uuid), + headers={'x-api-key': api_key, 'content-type': 'application/json'}, + data=json.dumps({'title': 'Example Title Test'}), + ) + + # Test search by URL + res = client.get(url_for("search")+"?q=https://example.com/page1", headers={'x-api-key': api_key, 'content-type': 'application/json'}) + assert len(res.json) == 1 + assert list(res.json.values())[0]['url'] == urls[0] + + # Test search by URL - partial should NOT match without ?partial=true flag + res = client.get(url_for("search")+"?q=https://example", headers={'x-api-key': api_key, 'content-type': 'application/json'}) + assert len(res.json) == 0 + + + # Test search by title + res = client.get(url_for("search")+"?q=Example Title Test", headers={'x-api-key': api_key, 'content-type': 'application/json'}) + assert len(res.json) == 1 + assert list(res.json.values())[0]['url'] == urls[0] + assert list(res.json.values())[0]['title'] == 'Example Title Test' + + # Test search that should return multiple results (partial = true) + res = client.get(url_for("search")+"?q=https://example&partial=true", headers={'x-api-key': api_key, 'content-type': 'application/json'}) + assert len(res.json) == 2 + + # Test empty search + res = client.get(url_for("search")+"?q=", headers={'x-api-key': api_key, 'content-type': 'application/json'}) + assert res.status_code == 400 + + # Add a tag to test search with tag filter + tag_name = 'test-tag' + res = client.post( + url_for("tag"), + data=json.dumps({"title": tag_name}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 201 + tag_uuid = res.json['uuid'] + + # Add the tag to one watch + for uuid, watch in watches_response.json.items(): + if urls[2] == watch['url']: + client.put( + url_for("watch", uuid=uuid), + headers={'x-api-key': api_key, 'content-type': 'application/json'}, + data=json.dumps({'tags': [tag_uuid]}), + ) + + + # Test search with tag filter and q + res = client.get(url_for("search") + f"?q={urls[2]}&tag={tag_name}", headers={'x-api-key': api_key, 'content-type': 'application/json'}) + assert len(res.json) == 1 + assert list(res.json.values())[0]['url'] == urls[2] + diff --git a/changedetectionio/tests/test_api_tags.py b/changedetectionio/tests/test_api_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..831d052e8be27b70ddc6738a16d35fab0acbe8a8 --- /dev/null +++ b/changedetectionio/tests/test_api_tags.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 + +from flask import url_for +from .util import live_server_setup, wait_for_all_checks +import json + +def test_api_tags_listing(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + tag_title = 'Test Tag' + + # Get a listing + res = client.get( + url_for("tags"), + headers={'x-api-key': api_key} + ) + assert res.text.strip() == "{}", "Should be empty list" + assert res.status_code == 200 + + res = client.post( + url_for("tag"), + data=json.dumps({"title": tag_title}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 201 + + new_tag_uuid = res.json.get('uuid') + + # List tags - should include our new tag + res = client.get( + url_for("tags"), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert new_tag_uuid in res.text + assert res.json[new_tag_uuid]['title'] == tag_title + assert res.json[new_tag_uuid]['notification_muted'] == False + + # Get single tag + res = client.get( + url_for("tag", uuid=new_tag_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json['title'] == tag_title + + # Update tag + res = client.put( + url_for("tag", uuid=new_tag_uuid), + data=json.dumps({"title": "Updated Tag"}), + headers={'content-type': 'application/json', 'x-api-key': api_key} + ) + assert res.status_code == 200 + assert b'OK' in res.data + + # Verify update worked + res = client.get( + url_for("tag", uuid=new_tag_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json['title'] == 'Updated Tag' + + # Mute tag notifications + res = client.get( + url_for("tag", uuid=new_tag_uuid) + "?muted=muted", + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert b'OK' in res.data + + # Verify muted status + res = client.get( + url_for("tag", uuid=new_tag_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json['notification_muted'] == True + + # Unmute tag + res = client.get( + url_for("tag", uuid=new_tag_uuid) + "?muted=unmuted", + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert b'OK' in res.data + + # Verify unmuted status + res = client.get( + url_for("tag", uuid=new_tag_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert res.json['notification_muted'] == False + + # Create a watch with the tag and check it matches UUID + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("createwatch"), + data=json.dumps({"url": test_url, "tag": "Updated Tag", "title": "Watch with tag"}), + headers={'content-type': 'application/json', 'x-api-key': api_key}, + follow_redirects=True + ) + assert res.status_code == 201 + watch_uuid = res.json.get('uuid') + + # Verify tag is associated with watch by name if need be + res = client.get( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert new_tag_uuid in res.json.get('tags', []) + + # Delete tag + res = client.delete( + url_for("tag", uuid=new_tag_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 204 + + # Verify tag is gone + res = client.get( + url_for("tags"), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert new_tag_uuid not in res.text + + # Verify tag was removed from watch + res = client.get( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key} + ) + assert res.status_code == 200 + assert new_tag_uuid not in res.json.get('tags', []) + + # Delete the watch + res = client.delete( + url_for("watch", uuid=watch_uuid), + headers={'x-api-key': api_key}, + ) + assert res.status_code == 204 diff --git a/changedetectionio/tests/test_auth.py b/changedetectionio/tests/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..a98599619715b3af8250806e33972e2ed1eb935c --- /dev/null +++ b/changedetectionio/tests/test_auth.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + +# test pages with http://username@password:foobar.com/ work +def test_basic_auth(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + + + # This page will echo back any auth info + test_url = url_for('test_basicauth_method', _external=True).replace("//","//myuser:mypass@") + time.sleep(1) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + time.sleep(1) + # Check form validation + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + wait_for_all_checks(client) + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b'myuser mypass basic' in res.data diff --git a/changedetectionio/tests/test_automatic_follow_ldjson_price.py b/changedetectionio/tests/test_automatic_follow_ldjson_price.py new file mode 100644 index 0000000000000000000000000000000000000000..c730286c90803daa011927e911c611c5f68822b8 --- /dev/null +++ b/changedetectionio/tests/test_automatic_follow_ldjson_price.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, extract_UUID_from_client, wait_for_all_checks + + +def set_response_with_ldjson(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text thats the same
    +
    Some text that will change
    + + + +""" + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + +def set_response_without_ldjson(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text thats the same
    +
    Some text that will change
    + + +""" + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + +# def test_setup(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + +# actually only really used by the distll.io importer, but could be handy too +def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage): + + set_response_with_ldjson() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Should get a notice that it's available + res = client.get(url_for("watchlist.index")) + assert b'ldjson-price-track-offer' in res.data + + # Accept it + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + #time.sleep(1) + client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True)) + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + # Offer should be gone + res = client.get(url_for("watchlist.index")) + assert b'Embedded price data' not in res.data + assert b'tracking-ldjson-price-data' in res.data + + # and last snapshop (via API) should be just the price + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + res = client.get( + url_for("watchsinglehistory", uuid=uuid, timestamp='latest'), + headers={'x-api-key': api_key}, + ) + + assert b'8097000' in res.data + + # And not this cause its not the ld-json + assert b"So let's see what happens" not in res.data + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + ########################################################################################## + # And we shouldnt see the offer + set_response_without_ldjson() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'ldjson-price-track-offer' not in res.data + + ########################################################################################## + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + +def _test_runner_check_bad_format_ignored(live_server, client, has_ldjson_price_data): + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + for k,v in client.application.config.get('DATASTORE').data['watching'].items(): + assert v.get('last_error') == False + assert v.get('has_ldjson_price_data') == has_ldjson_price_data, f"Detected LDJSON data? should be {has_ldjson_price_data}" + + + ########################################################################################## + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + +def test_bad_ldjson_is_correctly_ignored(client, live_server, measure_memory_usage): + + test_return_data = """ + + + + + +
    Some extra stuff
    + + """ + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + _test_runner_check_bad_format_ignored(live_server=live_server, client=client, has_ldjson_price_data=True) + + # This is OK that it offers a suggestion in this case, the processor will let them know more about something wrong + + # test_return_data = """ + # + # + # + # + # + #
    Some extra stuff
    + # + # """ + # with open("test-datastore/endpoint-content.txt", "w") as f: + # f.write(test_return_data) + # + # _test_runner_check_bad_format_ignored(live_server=live_server, client=client, has_ldjson_price_data=False) diff --git a/changedetectionio/tests/test_backend.py b/changedetectionio/tests/test_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..2cbbc530a13e3e776b847f032461731a0a13f0d6 --- /dev/null +++ b/changedetectionio/tests/test_backend.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \ + extract_UUID_from_client + +sleep_time_for_fetch_thread = 3 + + +# Basic test to check inscriptus is not adding return line chars, basically works etc +def test_inscriptus(): + from inscriptis import get_text + html_content = "test!
    ok man" + stripped_text_from_html = get_text(html_content) + assert stripped_text_from_html == 'test!\nok man' + + +def test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage): + set_original_response() + # live_server_setup(live_server) # Setup on conftest per function + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": url_for('test_endpoint', _external=True)}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + wait_for_all_checks(client) + + # Do this a few times.. ensures we dont accidently set the status + for n in range(3): + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + assert b'test-endpoint' in res.data + + # Default no password set, this stuff should be always available. + + assert b"SETTINGS" in res.data + assert b"BACKUP" in res.data + assert b"IMPORT" in res.data + + ##################### + + # Check HTML conversion detected and workd + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + # Check this class does not appear (that we didnt see the actual source) + assert b'foobar-detection' not in res.data + + # Make a change + set_modified_response() + + # Force recheck + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + wait_for_all_checks(client) + + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + + # Check the 'get latest snapshot works' + res = client.get(url_for("ui.ui_edit.watch_get_latest_html", uuid=uuid)) + assert b'which has this one new line' in res.data + + # Now something should be ready, indicated by having a 'unviewed' class + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + # #75, and it should be in the RSS feed + rss_token = extract_rss_token_from_UI(client) + res = client.get(url_for("rss.feed", token=rss_token, _external=True)) + expected_url = url_for('test_endpoint', _external=True) + assert b'= 2 + + backup = ZipFile(io.BytesIO(res.data)) + l = backup.namelist() + uuid4hex = re.compile('^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}.*txt', re.I) + newlist = list(filter(uuid4hex.match, l)) # Read Note below + + # Should be two txt files in the archive (history and the snapshot) + assert len(newlist) == 2 + + # Get the latest one + res = client.get( + url_for("backups.remove_backups"), + follow_redirects=True + ) + + assert b'No backups found.' in res.data \ No newline at end of file diff --git a/changedetectionio/tests/test_basic_socketio.py b/changedetectionio/tests/test_basic_socketio.py new file mode 100644 index 0000000000000000000000000000000000000000..1c48e758badf5daacefc47b7ad75f1ffd9cf9ee6 --- /dev/null +++ b/changedetectionio/tests/test_basic_socketio.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import ( + set_original_response, + set_modified_response, + live_server_setup, + wait_for_all_checks +) +from loguru import logger + +def run_socketio_watch_update_test(client, live_server, password_mode=""): + """Test that the socketio emits a watch update event when content changes""" + + # Set up the test server + set_original_response() + + # Get the SocketIO instance from the app + from changedetectionio.flask_app import app + socketio = app.extensions['socketio'] + + # Create a test client for SocketIO + socketio_test_client = socketio.test_client(app, flask_test_client=client) + if password_mode == "not logged in, should exit on connect": + assert not socketio_test_client.is_connected(), "Failed to connect to Socket.IO server because it should bounce this connect" + return + + assert socketio_test_client.is_connected(), "Failed to connect to Socket.IO server" + print("Successfully connected to Socket.IO server") + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": url_for('test_endpoint', _external=True)}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + res = client.get(url_for("watchlist.index")) + assert url_for('test_endpoint', _external=True).encode() in res.data + + # Wait for initial check to complete + wait_for_all_checks(client) + + # Clear any initial messages + socketio_test_client.get_received() + + # Make a change to trigger an update + set_modified_response() + + # Force recheck + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + # Wait for the watch to be checked + wait_for_all_checks(client) + + has_watch_update = False + has_unviewed_update = False + + for i in range(10): + # Get received events + received = socketio_test_client.get_received() + + if received: + logger.info(f"Received {len(received)} events after {i+1} seconds") + + # Check for watch_update events with unviewed=True + for event in received: + if event['name'] == 'watch_update': + has_watch_update = True + if event['args'][0]['watch'].get('unviewed', False): + has_unviewed_update = True + logger.info("Found unviewed update event!") + break + + if has_unviewed_update: + break + + # Force a recheck every 5 seconds to ensure events are emitted +# if i > 0 and i % 5 == 0: +# print(f"Still waiting for events, forcing another recheck...") +# res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) +# assert b'Queued 1 watch for rechecking.' in res.data +# wait_for_all_checks(client) + +# print(f"Waiting for unviewed update event... {i+1}/{max_wait}") + time.sleep(1) + + # Verify we received watch_update events + assert has_watch_update, "No watch_update events received" + + # Verify we received an unviewed event + assert has_unviewed_update, "No watch_update event with unviewed=True received" + + # Alternatively, check directly if the watch in the datastore is marked as unviewed + from changedetectionio.flask_app import app + datastore = app.config.get('DATASTORE') + + watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + + # Get the watch from the datastore + watch = datastore.data['watching'].get(watch_uuid) + assert watch, f"Watch {watch_uuid} not found in datastore" + assert watch.has_unviewed, "The watch was not marked as unviewed after content change" + + # Clean up + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + +def test_everything(live_server, client): + + # live_server_setup(live_server) # Setup on conftest per function + + run_socketio_watch_update_test(password_mode="", live_server=live_server, client=client) + + ############################ Password required auth check ############################## + + # Enable password check and diff page access bypass + res = client.post( + url_for("settings.settings_page"), + data={"application-password": "foobar", + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Password protection enabled." in res.data + + run_socketio_watch_update_test(password_mode="not logged in, should exit on connect", live_server=live_server, client=client) + res = client.post( + url_for("login"), + data={"password": "foobar"}, + follow_redirects=True + ) + + # Yes we are correctly logged in + assert b"LOG OUT" in res.data + run_socketio_watch_update_test(password_mode="should be like normal", live_server=live_server, client=client) diff --git a/changedetectionio/tests/test_block_while_text_present.py b/changedetectionio/tests/test_block_while_text_present.py new file mode 100644 index 0000000000000000000000000000000000000000..6ab36855fea417f13004d16a7771ad875490f2d1 --- /dev/null +++ b/changedetectionio/tests/test_block_while_text_present.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks +from changedetectionio import html_tools + +def set_original_ignore_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def set_modified_original_ignore_response(): + test_return_data = """ + + Some NEW nice initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +

    new ignore stuff

    +

    out of stock

    +

    blah

    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +# Is the same but includes ZZZZZ, 'ZZZZZ' is the last line in ignore_text +def set_modified_response_minus_block_text(): + test_return_data = """ + + Some NEW nice initial text
    +

    Which is across multiple lines

    +

    now on sale $2/p> +
    + So let's see what happens.
    +

    new ignore stuff

    +

    blah

    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def test_check_block_changedetection_text_NOT_present(client, live_server, measure_memory_usage): + + # live_server_setup(live_server) # Setup on conftest per function + # Use a mix of case in ZzZ to prove it works case-insensitive. + ignore_text = "out of stoCk\r\nfoobar" + set_original_ignore_response() + + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"text_should_not_be_present": ignore_text, + "url": test_url, + 'fetch_backend': "html_requests" + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + # Check it saved + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + ) + assert bytes(ignore_text.encode('utf-8')) in res.data + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + assert b'/test-endpoint' in res.data + + # The page changed, BUT the text is still there, just the rest of it changes, we should not see a change + set_modified_original_ignore_response() + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + assert b'/test-endpoint' in res.data + + # 2548 + # Going back to the ORIGINAL should NOT trigger a change + set_original_ignore_response() + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + + # Now we set a change where the text is gone AND its different content, it should now trigger + set_modified_response_minus_block_text() + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + + + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test_clone.py b/changedetectionio/tests/test_clone.py new file mode 100644 index 0000000000000000000000000000000000000000..aeb3b4f2a62b39411cbfe60c54c74c518ee93829 --- /dev/null +++ b/changedetectionio/tests/test_clone.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + + +def test_clone_functionality(client, live_server, measure_memory_usage): + + # live_server_setup(live_server) # Setup on conftest per function + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write("Some content") + + test_url = url_for('test_endpoint', _external=True) + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # So that we can be sure the same history doesnt carry over + time.sleep(1) + + res = client.get( + url_for("ui.form_clone", uuid="first"), + follow_redirects=True + ) + existing_uuids = set() + + for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items(): + new_uuids = set(watch.history.keys()) + duplicates = existing_uuids.intersection(new_uuids) + assert len(duplicates) == 0 + existing_uuids.update(new_uuids) + + assert b"Cloned" in res.data diff --git a/changedetectionio/tests/test_conditions.py b/changedetectionio/tests/test_conditions.py new file mode 100644 index 0000000000000000000000000000000000000000..9c6fae45d5bf694adc01c7a18078a8cb0ba903d6 --- /dev/null +++ b/changedetectionio/tests/test_conditions.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +import json +import time + +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + +def set_original_response(number="50"): + test_return_data = f""" + +

    Test Page for Conditions

    +

    This page contains a number that will be tested with conditions.

    +
    Current value: {number}
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + +def set_number_in_range_response(number="75"): + test_return_data = f""" + +

    Test Page for Conditions

    +

    This page contains a number that will be tested with conditions.

    +
    Current value: {number}
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + +def set_number_out_of_range_response(number="150"): + test_return_data = f""" + +

    Test Page for Conditions

    +

    This page contains a number that will be tested with conditions.

    +
    Current value: {number}
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +# def test_setup(client, live_server): + """Test that both text and number conditions work together with AND logic.""" + # live_server_setup(live_server) # Setup on conftest per function + +def test_conditions_with_text_and_number(client, live_server): + """Test that both text and number conditions work together with AND logic.""" + + set_original_response("50") + + + test_url = url_for('test_endpoint', _external=True) + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Configure the watch with two conditions connected with AND: + # 1. The page filtered text must contain "5" (first digit of value) + # 2. The extracted number should be >= 20 and <= 100 + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "url": test_url, + "fetch_backend": "html_requests", + "include_filters": ".number-container", + "title": "Number AND Text Condition Test", + "conditions_match_logic": "ALL", # ALL = AND logic + "conditions-0-operator": "in", + "conditions-0-field": "page_filtered_text", + "conditions-0-value": "5", + + "conditions-1-operator": ">=", + "conditions-1-field": "extracted_number", + "conditions-1-value": "20", + + "conditions-2-operator": "<=", + "conditions-2-field": "extracted_number", + "conditions-2-value": "100", + + # So that 'operations' from pluggy discovery are tested + "conditions-3-operator": "length_min", + "conditions-3-field": "page_filtered_text", + "conditions-3-value": "1", + + # So that 'operations' from pluggy discovery are tested + "conditions-4-operator": "length_max", + "conditions-4-field": "page_filtered_text", + "conditions-4-value": "100", + + # So that 'operations' from pluggy discovery are tested + "conditions-5-operator": "contains_regex", + "conditions-5-field": "page_filtered_text", + "conditions-5-value": "\d", + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + wait_for_all_checks(client) + client.get(url_for("ui.mark_all_viewed"), follow_redirects=True) + time.sleep(0.2) + + wait_for_all_checks(client) + + # Case 1 + set_number_in_range_response("70.5") + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + time.sleep(2) + # 75 is > 20 and < 100 and contains "5" + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + + # Case 2: Change with one condition violated + # Number out of range (150) but contains '5' + client.get(url_for("ui.mark_all_viewed"), follow_redirects=True) + time.sleep(0.2) + + set_number_out_of_range_response("150.5") + + + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + # Should NOT be marked as having changes since not all conditions are met + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +# The 'validate' button next to each rule row +def test_condition_validate_rule_row(client, live_server): + + set_original_response("50") + + test_url = url_for('test_endpoint', _external=True) + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + + # the front end submits the current form state which should override the watch in a temporary copy + res = client.post( + url_for("conditions.verify_condition_single_rule", watch_uuid=uuid), # Base URL + query_string={"rule": json.dumps({"field": "extracted_number", "operator": "==", "value": "50"})}, + data={'include_filter': ""}, + follow_redirects=True + ) + assert res.status_code == 200 + assert b'success' in res.data + + # Now a number that does not equal what is found in the last fetch + res = client.post( + url_for("conditions.verify_condition_single_rule", watch_uuid=uuid), # Base URL + query_string={"rule": json.dumps({"field": "extracted_number", "operator": "==", "value": "111111"})}, + data={'include_filter': ""}, + follow_redirects=True + ) + assert res.status_code == 200 + assert b'false' in res.data + + # Now custom filter that exists + res = client.post( + url_for("conditions.verify_condition_single_rule", watch_uuid=uuid), # Base URL + query_string={"rule": json.dumps({"field": "extracted_number", "operator": "==", "value": "50"})}, + data={'include_filter': ".number-container"}, + follow_redirects=True + ) + assert res.status_code == 200 + assert b'success' in res.data + + # Now custom filter that DOES NOT exists + res = client.post( + url_for("conditions.verify_condition_single_rule", watch_uuid=uuid), # Base URL + query_string={"rule": json.dumps({"field": "extracted_number", "operator": "==", "value": "50"})}, + data={'include_filters': ".NOT-container"}, + follow_redirects=True + ) + assert res.status_code == 200 + assert b'false' in res.data + # cleanup for the next + client.get( + url_for("ui.form_delete", uuid="all"), + follow_redirects=True + ) + + + +# If there was only a change in the whitespacing, then we shouldnt have a change detected +def test_wordcount_conditions_plugin(client, live_server, measure_memory_usage): + + + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # Check it saved + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + ) + + # Assert the word count is counted correctly + assert b'13' in res.data + + # cleanup for the next + client.get( + url_for("ui.form_delete", uuid="all"), + follow_redirects=True + ) + +# If there was only a change in the whitespacing, then we shouldnt have a change detected +def test_lev_conditions_plugin(client, live_server, measure_memory_usage): + + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(""" + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + """) + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + assert b"Watch added in Paused state, saving will unpause" in res.data + + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + # Give the thread time to pick it up + wait_for_all_checks(client) + res = client.post( + url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1), + data={ + "url": test_url, + "fetch_backend": "html_requests", + "conditions_match_logic": "ALL", # ALL = AND logic + "conditions-0-field": "levenshtein_ratio", + "conditions-0-operator": "<", + "conditions-0-value": "0.8" # needs to be more of a diff to trigger a change + }, + follow_redirects=True + ) + + assert b"unpaused" in res.data + + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + # Check the content saved initially, even tho a condition was set - this is the first snapshot so shouldnt be affected by conditions + res = client.get( + url_for("ui.ui_views.preview_page", uuid=uuid), + follow_redirects=True + ) + assert b'Which is across multiple lines' in res.data + + + ############### Now change it a LITTLE bit... + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(""" + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happenxxxxxxxxx.
    + + + """) + + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data #because this will be like 0.90 not 0.8 threshold + + ############### Now change it a MORE THAN 50% + test_return_data = """ + + Some sxxxx
    +

    Which is across a lines

    +
    + ok.
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + # cleanup for the next + client.get( + url_for("ui.form_delete", uuid="all"), + follow_redirects=True + ) \ No newline at end of file diff --git a/changedetectionio/tests/test_css_selector.py b/changedetectionio/tests/test_css_selector.py new file mode 100644 index 0000000000000000000000000000000000000000..bad181c251b99e768f6d79419fb8adc3d0c8d3fd --- /dev/null +++ b/changedetectionio/tests/test_css_selector.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + +from ..html_tools import * + + + +def set_original_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text thats the same
    +
    Some text that will change
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + +def set_modified_response(): + test_return_data = """ + + Some initial text
    +

    which has this one new line

    +
    + So let's see what happens.
    +
    Some text thats the same
    +
    Some text that changes
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + return None + + +# Test that the CSS extraction works how we expect, important here is the right placing of new lines \n's +def test_include_filters_output(): + from inscriptis import get_text + + # Check text with sub-parts renders correctly + content = """
    Some really bold text
    """ + html_blob = include_filters(include_filters="#thingthing", html_content=content) + text = get_text(html_blob) + assert text == " Some really bold text" + + content = """ +

    foo bar blah

    +
    Block A
    Block B
    + +""" + + # in xPath this would be //*[@class='parts'] + html_blob = include_filters(include_filters=".parts", html_content=content) + text = get_text(html_blob) + + # Divs are converted to 4 whitespaces by inscriptis + assert text == " Block A\n Block B" + + +# Tests the whole stack works with the CSS Filter +def test_check_markup_include_filters_restriction(client, live_server, measure_memory_usage): + sleep_time_for_fetch_thread = 3 + + include_filters = "#sametext" + + set_original_response() + + # Give the endpoint time to spin up + time.sleep(1) + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + time.sleep(sleep_time_for_fetch_thread) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": include_filters, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + time.sleep(1) + # Check it saved + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + ) + assert bytes(include_filters.encode('utf-8')) in res.data + + # Give the thread time to pick it up + time.sleep(sleep_time_for_fetch_thread) + # Make a change + set_modified_response() + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + # Give the thread time to pick it up + time.sleep(sleep_time_for_fetch_thread) + + # It should have 'unviewed' still + # Because it should be looking at only that 'sametext' id + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + +# Tests the whole stack works with the CSS Filter +def test_check_multiple_filters(client, live_server, measure_memory_usage): + + include_filters = "#blob-a\r\nxpath://*[contains(@id,'blob-b')]" + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(""" +
    Blob A
    +
    Blob B
    +
    Blob C
    + + + """) + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": include_filters, + "url": test_url, + "tags": "", + "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + # Only the two blobs should be here + assert b"Blob A" in res.data # CSS was ok + assert b"Blob B" in res.data # xPath was ok + assert b"Blob C" not in res.data # Should not be included + +# The filter exists, but did not contain anything useful +# Mainly used when the filter contains just an IMG, this can happen when someone selects an image in the visual-selector +# Tests fetcher can throw a "ReplyWithContentButNoText" exception after applying filter and extracting text +def test_filter_is_empty_help_suggestion(client, live_server, measure_memory_usage): + + + include_filters = "#blob-a" + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(""" +
    + +
    + + + """) + + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": include_filters, + "url": test_url, + "tags": "", + "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + wait_for_all_checks(client) + + + res = client.get( + url_for("watchlist.index"), + follow_redirects=True + ) + + assert b'empty result or contain only an image' in res.data + + + ### Just an empty selector, no image + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(""" +
    + +
    + + + """) + + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + res = client.get( + url_for("watchlist.index"), + follow_redirects=True + ) + + assert b'empty result or contain only an image' not in res.data + assert b'but contained no usable text' in res.data diff --git a/changedetectionio/tests/test_element_removal.py b/changedetectionio/tests/test_element_removal.py new file mode 100644 index 0000000000000000000000000000000000000000..36643b7137cbdaa55de02cee12054136eff622f3 --- /dev/null +++ b/changedetectionio/tests/test_element_removal.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 + +import time + +from flask import url_for + +from ..html_tools import * +from .util import live_server_setup, wait_for_all_checks + + + + +def set_response_with_multiple_index(): + data= """ + + + + + + + + + + + + + + + + + + + + +
    Person 1Person 2Person 3
    EmilTobiasLinus
    161410
    + + +""" + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(data) + + +def set_original_response(): + test_return_data = """ +
    +

    Header

    +
    + + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text that will change
    + +
    +

    Footer

    +
    + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def set_modified_response(): + test_return_data = """ +
    +

    Header changed

    +
    + + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text that changes
    + +
    +

    Footer changed

    +
    + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def test_element_removal_output(): + from inscriptis import get_text + + # Check text with sub-parts renders correctly + content = """ +
    +

    Header

    +
    + + + Some initial text
    +

    across multiple lines

    +
    Some text that changes
    +
    Some text should be matched by xPath // selector
    +
    Some text should be matched by xPath selector
    +
    Some text should be matched by xPath1 selector
    + +
    +

    Footer

    +
    + + """ + html_blob = element_removal( + [ + "header", + "footer", + "nav", + "#changetext", + "//*[contains(text(), 'xPath // selector')]", + "xpath://*[contains(text(), 'xPath selector')]", + "xpath1://*[contains(text(), 'xPath1 selector')]" + ], + html_content=content + ) + text = get_text(html_blob) + assert ( + text + == """Some initial text + +across multiple lines +""" + ) + + +def test_element_removal_full(client, live_server, measure_memory_usage): + + + set_original_response() + + + # Add our URL to the import page + test_url = url_for("test_endpoint", _external=True) + res = client.post( + url_for("imports.import_page"), data={"urls": test_url}, follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Goto the edit page, add the filter data + # Not sure why \r needs to be added - absent of the #changetext this is not necessary + subtractive_selectors_data = "header\r\nfooter\r\nnav\r\n#changetext" + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "subtractive_selectors": subtractive_selectors_data, + "url": test_url, + "tags": "", + "headers": "", + "fetch_backend": "html_requests", + }, + follow_redirects=True, + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + # Check it saved + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + ) + assert bytes(subtractive_selectors_data.encode("utf-8")) in res.data + + # Trigger a check + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + wait_for_all_checks(client) + + # so that we set the state to 'unviewed' after all the edits + client.get(url_for("ui.ui_views.diff_history_page", uuid="first")) + + # Make a change to header/footer/nav + set_modified_response() + + # Trigger a check + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # There should not be an unviewed change, as changes should be removed + res = client.get(url_for("watchlist.index")) + assert b"unviewed" not in res.data + +# Re #2752 +def test_element_removal_nth_offset_no_shift(client, live_server, measure_memory_usage): + + + set_response_with_multiple_index() + subtractive_selectors_data = [""" +body > table > tr:nth-child(1) > th:nth-child(2) +body > table > tr:nth-child(2) > td:nth-child(2) +body > table > tr:nth-child(3) > td:nth-child(2) +body > table > tr:nth-child(1) > th:nth-child(3) +body > table > tr:nth-child(2) > td:nth-child(3) +body > table > tr:nth-child(3) > td:nth-child(3)""", +"""//body/table/tr[1]/th[2] +//body/table/tr[2]/td[2] +//body/table/tr[3]/td[2] +//body/table/tr[1]/th[3] +//body/table/tr[2]/td[3] +//body/table/tr[3]/td[3]"""] + + for selector_list in subtractive_selectors_data: + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + # Add our URL to the import page + test_url = url_for("test_endpoint", _external=True) + res = client.post( + url_for("imports.import_page"), data={"urls": test_url}, follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "subtractive_selectors": selector_list, + "url": test_url, + "tags": "", + "fetch_backend": "html_requests", + }, + follow_redirects=True, + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b"Tobias" not in res.data + assert b"Linus" not in res.data + assert b"Person 2" not in res.data + assert b"Person 3" not in res.data + # First column should exist + assert b"Emil" in res.data + diff --git a/changedetectionio/tests/test_encoding.py b/changedetectionio/tests/test_encoding.py new file mode 100644 index 0000000000000000000000000000000000000000..722d1e6b81b248f3675a4086036a118ad5f3df99 --- /dev/null +++ b/changedetectionio/tests/test_encoding.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client +import pytest + + + + + +def set_html_response(): + test_return_data = """ + +        铸大国重器,挺制造脊梁,致力能源未来,赋能美好生活。 + + + """ + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + + +# In the case the server does not issue a charset= or doesnt have content_type header set +def test_check_encoding_detection(client, live_server, measure_memory_usage): + set_html_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', content_type="text/html", _external=True) + client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + # Give the thread time to pick it up + wait_for_all_checks(client) + + + # Content type recording worked + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['content-type'] == "text/html" + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + # Should see the proper string + assert "铸大国重".encode('utf-8') in res.data + # Should not see the failed encoding + assert b'\xc2\xa7' not in res.data + + +# In the case the server does not issue a charset= or doesnt have content_type header set +def test_check_encoding_detection_missing_content_type_header(client, live_server, measure_memory_usage): + set_html_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + # Should see the proper string + assert "铸大国重".encode('utf-8') in res.data + # Should not see the failed encoding + assert b'\xc2\xa7' not in res.data diff --git a/changedetectionio/tests/test_errorhandling.py b/changedetectionio/tests/test_errorhandling.py new file mode 100644 index 0000000000000000000000000000000000000000..27b9a3188bd495330b9e51c30fecb1e56a2f6378 --- /dev/null +++ b/changedetectionio/tests/test_errorhandling.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 + +import time + +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + + + + +def _runner_test_http_errors(client, live_server, http_code, expected_text): + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write("Now you going to get a {} error code\n".format(http_code)) + + + # Add our URL to the import page + test_url = url_for('test_endpoint', + status_code=http_code, + _external=True) + + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + # no change + assert b'unviewed' not in res.data + assert bytes(expected_text.encode('utf-8')) in res.data + + + # Error viewing tabs should appear + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b'Error Text' in res.data + + # 'Error Screenshot' only when in playwright mode + #assert b'Error Screenshot' in res.data + + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_http_error_handler(client, live_server, measure_memory_usage): + _runner_test_http_errors(client, live_server, 403, 'Access denied') + _runner_test_http_errors(client, live_server, 404, 'Page not found') + _runner_test_http_errors(client, live_server, 500, '(Internal server error) received') + _runner_test_http_errors(client, live_server, 400, 'Error - Request returned a HTTP error code 400') + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +# Just to be sure error text is properly handled +def test_DNS_errors(client, live_server, measure_memory_usage): + # Give the endpoint time to spin up + time.sleep(1) + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": "https://errorfuldomainthatnevereallyexists12356.com"}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + found_name_resolution_error = ( + b"No address found" in res.data or + b"Name or service not known" in res.data or + b"nodename nor servname provided" in res.data or + b"Temporary failure in name resolution" in res.data or + b"Failed to establish a new connection" in res.data or + b"Connection error occurred" in res.data + ) + assert found_name_resolution_error + # Should always record that we tried + assert bytes("just now".encode('utf-8')) in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +# Re 1513 +def test_low_level_errors_clear_correctly(client, live_server, measure_memory_usage): + + # Give the endpoint time to spin up + time.sleep(1) + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write("
    Hello world
    ") + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + + res = client.post( + url_for("imports.import_page"), + data={"urls": "https://dfkjasdkfjaidjfsdajfksdajfksdjfDOESNTEXIST.com"}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # We should see the DNS error + res = client.get(url_for("watchlist.index")) + found_name_resolution_error = ( + b"No address found" in res.data or + b"Name or service not known" in res.data or + b"nodename nor servname provided" in res.data or + b"Temporary failure in name resolution" in res.data or + b"Failed to establish a new connection" in res.data or + b"Connection error occurred" in res.data + ) + assert found_name_resolution_error + + # Update with what should work + client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "url": test_url, + "fetch_backend": "html_requests"}, + follow_redirects=True + ) + + # Now the error should be gone + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + found_name_resolution_error = ( + b"No address found" in res.data or + b"Name or service not known" in res.data or + b"nodename nor servname provided" in res.data or + b"Temporary failure in name resolution" in res.data or + b"Failed to establish a new connection" in res.data or + b"Connection error occurred" in res.data + ) + assert not found_name_resolution_error + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test_extract_csv.py b/changedetectionio/tests/test_extract_csv.py new file mode 100644 index 0000000000000000000000000000000000000000..e70c41b26bc79b1d7079a94d838279b0dd87d362 --- /dev/null +++ b/changedetectionio/tests/test_extract_csv.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from urllib.request import urlopen +from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks + +sleep_time_for_fetch_thread = 3 + + + +def test_check_extract_text_from_diff(client, live_server, measure_memory_usage): + import time + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write("Now it's {} seconds since epoch, time flies!".format(str(time.time()))) + + # live_server_setup(live_server) # Setup on conftest per function + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": url_for('test_endpoint', _external=True)}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Load in 5 different numbers/changes + last_date="" + for n in range(5): + time.sleep(1) + # Give the thread time to pick it up + print("Bumping snapshot and checking.. ", n) + last_date = str(time.time()) + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write("Now it's {} seconds since epoch, time flies!".format(last_date)) + + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_views.diff_history_page", uuid="first"), + data={"extract_regex": "Now it's ([0-9\.]+)", + "extract_submit_button": "Extract as CSV"}, + follow_redirects=False + ) + + assert b'Nothing matches that RegEx' not in res.data + assert res.content_type == 'text/csv' + + # Read the csv reply as stringio + from io import StringIO + import csv + + f = StringIO(res.data.decode('utf-8')) + reader = csv.reader(f, delimiter=',') + output=[] + + for row in reader: + output.append(row) + + assert output[0][0] == 'Epoch seconds' + + # Header line + 1 origin/first + 5 changes + assert(len(output) == 7) + + # We expect to find the last bumped date in the changes in the last field of the spreadsheet + assert(output[6][2] == last_date) + # And nothing else, only that group () of the decimal and . + assert "time flies" not in output[6][2] diff --git a/changedetectionio/tests/test_extract_regex.py b/changedetectionio/tests/test_extract_regex.py new file mode 100644 index 0000000000000000000000000000000000000000..3b270d3fa9194d89a375b2f8b8323b0fc4d3617b --- /dev/null +++ b/changedetectionio/tests/test_extract_regex.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + +from ..html_tools import * + + +def set_original_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text thats the same
    +
    Some text that will change
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + + +def set_modified_response(): + test_return_data = """ + + Some initial text
    +

    which has this one new line

    +
    + So let's see what happens.
    +
    Some text thats the same
    +
    Some text that did change ( 1000 online
    80 guests
    2000 online )
    +
    SomeCase insensitive 3456
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + return None + + +def set_multiline_response(): + test_return_data = """ + + +

    Something
    + across 6 billion multiple
    + lines +

    + +
    aaand something lines
    +
    +
    and this should be
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + return None + + +# def test_setup(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + +def test_check_filter_multiline(client, live_server, measure_memory_usage): + ## live_server_setup(live_server) # Setup on conftest per function + set_multiline_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + wait_for_all_checks(client) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": '', + # Test a regex and a plaintext + 'extract_text': '/something.+?6 billion.+?lines/si\r\nand this should be', + "url": test_url, + "tags": "", + "headers": "", + 'fetch_backend': "html_requests" + }, + follow_redirects=True + ) + + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + + # Issue 1828 + assert b'not at the start of the expression' not in res.data + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + # Plaintext that doesnt look like a regex should match also + assert b'and this should be' in res.data + + assert b'Something' in res.data + assert b'across 6 billion multiple' in res.data + assert b'lines' in res.data + + # but the last one, which also says 'lines' shouldnt be here (non-greedy match checking) + assert b'aaand something lines' not in res.data + +def test_check_filter_and_regex_extract(client, live_server, measure_memory_usage): + + include_filters = ".changetext" + + set_original_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": include_filters, + 'extract_text': '/\d+ online/\r\n/\d+ guests/\r\n/somecase insensitive \d+/i\r\n/somecase insensitive (345\d)/i\r\n/issue1828.+?2022/i', + "url": test_url, + "tags": "", + "headers": "", + 'fetch_backend': "html_requests" + }, + follow_redirects=True + ) + + assert b"Updated watch." in res.data + + + # Give the thread time to pick it up + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + #issue 1828 + assert b'not at the start of the expression' not in res.data + + # Make a change + set_modified_response() + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should have 'unviewed' still + # Because it should be looking at only that 'sametext' id + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + # Check HTML conversion detected and workd + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b'1000 online' in res.data + + # All regex matching should be here + assert b'2000 online' in res.data + + # Both regexs should be here + assert b'80 guests' in res.data + + # Regex with flag handling should be here + assert b'SomeCase insensitive 3456' in res.data + + # Singular group from /somecase insensitive (345\d)/i + assert b'3456' in res.data + + # Regex with multiline flag handling should be here + + # Should not be here + assert b'Some text that did change' not in res.data + + + +def test_regex_error_handling(client, live_server, measure_memory_usage): + + + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + ### test regex error handling + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"extract_text": '/something bad\d{3/XYZ', + "url": test_url, + "fetch_backend": "html_requests"}, + follow_redirects=True + ) + + assert b'is not a valid regular expression.' in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test_filter_exist_changes.py b/changedetectionio/tests/test_filter_exist_changes.py new file mode 100644 index 0000000000000000000000000000000000000000..c7841bad73dc004d2c042cd18ee9dd3935932ad5 --- /dev/null +++ b/changedetectionio/tests/test_filter_exist_changes.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 + +# https://www.reddit.com/r/selfhosted/comments/wa89kp/comment/ii3a4g7/?context=3 +import os +import time +from flask import url_for +from .util import set_original_response, live_server_setup, wait_for_notification_endpoint_output +from changedetectionio.model import App + + +def set_response_without_filter(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text thats the same
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + + +def set_response_with_filter(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Ticket now on sale!
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + +def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_server, measure_memory_usage): +# Filter knowingly doesn't exist, like someone setting up a known filter to see if some cinema tickets are on sale again +# And the page has that filter available +# Then I should get a notification + + # live_server_setup(live_server) # Setup on conftest per function + + # Give the endpoint time to spin up + time.sleep(1) + set_response_without_filter() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'cinema'}, + follow_redirects=True + ) + assert b"Watch added" in res.data + + # Give the thread time to pick up the first version + time.sleep(3) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + url = url_for('test_notification_endpoint', _external=True) + notification_url = url.replace('http', 'json') + + print(">>>> Notification URL: " + notification_url) + + # Just a regular notification setting, this will be used by the special 'filter not found' notification + notification_form_data = {"notification_urls": notification_url, + "notification_title": "New ChangeDetection.io Notification - {{watch_url}}", + "notification_body": "BASE URL: {{base_url}}\n" + "Watch URL: {{watch_url}}\n" + "Watch UUID: {{watch_uuid}}\n" + "Watch title: {{watch_title}}\n" + "Watch tag: {{watch_tag}}\n" + "Preview: {{preview_url}}\n" + "Diff URL: {{diff_url}}\n" + "Snapshot: {{current_snapshot}}\n" + "Diff: {{diff}}\n" + "Diff Full: {{diff_full}}\n" + "Diff as Patch: {{diff_patch}}\n" + ":-)", + "notification_format": "Text"} + + notification_form_data.update({ + "url": test_url, + "tags": "my tag", + "title": "my title", + "headers": "", + "include_filters": '.ticket-available', + "fetch_backend": "html_requests"}) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data=notification_form_data, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_notification_endpoint_output() + + # Shouldn't exist, shouldn't have fired + assert not os.path.isfile("test-datastore/notification.txt") + # Now the filter should exist + set_response_with_filter() + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + wait_for_notification_endpoint_output() + + assert os.path.isfile("test-datastore/notification.txt") + + with open("test-datastore/notification.txt", 'r') as f: + notification = f.read() + + assert 'Ticket now on sale' in notification + os.unlink("test-datastore/notification.txt") diff --git a/changedetectionio/tests/test_filter_failure_notification.py b/changedetectionio/tests/test_filter_failure_notification.py new file mode 100644 index 0000000000000000000000000000000000000000..7023fb55acc2a4cf9942bfb791a46dc801de3376 --- /dev/null +++ b/changedetectionio/tests/test_filter_failure_notification.py @@ -0,0 +1,176 @@ +import os +import time +from loguru import logger +from flask import url_for +from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks, \ + wait_for_notification_endpoint_output +from changedetectionio.model import App + + +def set_response_with_filter(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text thats the same
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + +def run_filter_test(client, live_server, content_filter): + + # Response WITHOUT the filter ID element + set_original_response() + + # Goto the edit page, add our ignore text + notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + + # cleanup for the next + client.get( + url_for("ui.form_delete", uuid="all"), + follow_redirects=True + ) + if os.path.isfile("test-datastore/notification.txt"): + os.unlink("test-datastore/notification.txt") + + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure" + + watch_data = {"notification_urls": notification_url, + "notification_title": "New ChangeDetection.io Notification - {{watch_url}}", + "notification_body": "BASE URL: {{base_url}}\n" + "Watch URL: {{watch_url}}\n" + "Watch UUID: {{watch_uuid}}\n" + "Watch title: {{watch_title}}\n" + "Watch tag: {{watch_tag}}\n" + "Preview: {{preview_url}}\n" + "Diff URL: {{diff_url}}\n" + "Snapshot: {{current_snapshot}}\n" + "Diff: {{diff}}\n" + "Diff Full: {{diff_full}}\n" + "Diff as Patch: {{diff_patch}}\n" + ":-)", + "notification_format": "Text", + "fetch_backend": "html_requests", + "filter_failure_notification_send": 'y', + "headers": "", + "tags": "my tag", + "title": "my title 123", + "time_between_check-hours": 5, # So that the queue runner doesnt also put it in + "url": test_url, + } + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid=uuid), + data=watch_data, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure" + + # Now add a filter, because recheck hours == 5, ONLY pressing of the [edit] or [recheck all] should trigger + watch_data['include_filters'] = content_filter + res = client.post( + url_for("ui.ui_edit.edit_page", uuid=uuid), + data=watch_data, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + # It should have checked once so far and given this error (because we hit SAVE) + + wait_for_all_checks(client) + assert not os.path.isfile("test-datastore/notification.txt") + + # Hitting [save] would have triggered a recheck, and we have a filter, so this would be ONE failure + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 1, "Should have been checked once" + + # recheck it up to just before the threshold, including the fact that in the previous POST it would have rechecked (and incremented) + # Add 4 more checks + checked = 0 + ATTEMPT_THRESHOLD_SETTING = live_server.app.config['DATASTORE'].data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0) + for i in range(0, ATTEMPT_THRESHOLD_SETTING - 2): + checked += 1 + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'Warning, no filters were found' in res.data + assert not os.path.isfile("test-datastore/notification.txt") + time.sleep(1) + + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 5 + + time.sleep(2) + # One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + wait_for_notification_endpoint_output() + + # Now it should exist and contain our "filter not found" alert + assert os.path.isfile("test-datastore/notification.txt") + with open("test-datastore/notification.txt", 'r') as f: + notification = f.read() + + assert 'CSS/xPath filter was not present in the page' in notification + assert content_filter.replace('"', '\\"') in notification + + # Remove it and prove that it doesn't trigger when not expected + # It should register a change, but no 'filter not found' + os.unlink("test-datastore/notification.txt") + set_response_with_filter() + + # Try several times, it should NOT have 'filter not found' + for i in range(0, ATTEMPT_THRESHOLD_SETTING + 2): + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + wait_for_notification_endpoint_output() + # It should have sent a notification, but.. + assert os.path.isfile("test-datastore/notification.txt") + # but it should not contain the info about a failed filter (because there was none in this case) + with open("test-datastore/notification.txt", 'r') as f: + notification = f.read() + assert not 'CSS/xPath filter was not present in the page' in notification + + # Re #1247 - All tokens got replaced correctly in the notification + assert uuid in notification + + # cleanup for the next + client.get( + url_for("ui.form_delete", uuid="all"), + follow_redirects=True + ) + os.unlink("test-datastore/notification.txt") + + + + +def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage): +# # live_server_setup(live_server) # Setup on conftest per function + run_filter_test(client, live_server,'#nope-doesnt-exist') + +def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage): +# # live_server_setup(live_server) # Setup on conftest per function + run_filter_test(client, live_server, '//*[@id="nope-doesnt-exist"]') + +# Test that notification is never sent diff --git a/changedetectionio/tests/test_group.py b/changedetectionio/tests/test_group.py new file mode 100644 index 0000000000000000000000000000000000000000..e166a8daa5c375cc9b342f0be610b5b9e86f1601 --- /dev/null +++ b/changedetectionio/tests/test_group.py @@ -0,0 +1,483 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, get_UUID_for_tag_name, extract_UUID_from_client +import os + + +# def test_setup(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + +def set_original_response(): + test_return_data = """ + + Some initial text
    +

    Should be only this

    +
    +

    And never this

    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + +def set_modified_response(): + test_return_data = """ + + Some initial text
    +

    Should be REALLY only this

    +
    +

    And never this

    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + +def test_setup_group_tag(client, live_server, measure_memory_usage): + + set_original_response() + + # Add a tag with some config, import a tag and it should roughly work + res = client.post( + url_for("tags.form_tag_add"), + data={"name": "test-tag"}, + follow_redirects=True + ) + assert b"Tag added" in res.data + assert b"test-tag" in res.data + + res = client.post( + url_for("tags.form_tag_edit_submit", uuid="first"), + data={"name": "test-tag", + "include_filters": '#only-this', + "subtractive_selectors": '#not-this'}, + follow_redirects=True + ) + assert b"Updated" in res.data + tag_uuid = get_UUID_for_tag_name(client, name="test-tag") + res = client.get( + url_for("tags.form_tag_edit", uuid="first") + ) + assert b"#only-this" in res.data + assert b"#not-this" in res.data + + # Tag should be setup and ready, now add a watch + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url + "?first-imported=1 test-tag, extra-import-tag"}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + res = client.get(url_for("watchlist.index")) + assert b'import-tag' in res.data + assert b'extra-import-tag' in res.data + + res = client.get( + url_for("tags.tags_overview_page"), + follow_redirects=True + ) + assert b'import-tag' in res.data + assert b'extra-import-tag' in res.data + + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + assert b'Warning, no filters were found' not in res.data + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + assert b'Should be only this' in res.data + assert b'And never this' not in res.data + + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + follow_redirects=True + ) + # 2307 the UI notice should appear in the placeholder + assert b'WARNING: Watch has tag/groups set with special filters' in res.data + + # RSS Group tag filter + # An extra one that should be excluded + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url + "?should-be-excluded=1 some-tag"}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + set_modified_response() + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + rss_token = extract_rss_token_from_UI(client) + res = client.get( + url_for("rss.feed", token=rss_token, tag="extra-import-tag", _external=True), + follow_redirects=True + ) + assert b"should-be-excluded" not in res.data + assert res.status_code == 200 + assert b"first-imported=1" in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_tag_import_singular(client, live_server, measure_memory_usage): + + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url + " test-tag, test-tag\r\n"+ test_url + "?x=1 test-tag, test-tag\r\n"}, + follow_redirects=True + ) + assert b"2 Imported" in res.data + + res = client.get( + url_for("tags.tags_overview_page"), + follow_redirects=True + ) + # Should be only 1 tag because they both had the same + assert res.data.count(b'test-tag') == 1 + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_tag_add_in_ui(client, live_server, measure_memory_usage): + +# + res = client.post( + url_for("tags.form_tag_add"), + data={"name": "new-test-tag"}, + follow_redirects=True + ) + assert b"Tag added" in res.data + assert b"new-test-tag" in res.data + + res = client.get(url_for("tags.delete_all"), follow_redirects=True) + assert b'All tags deleted' in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_group_tag_notification(client, live_server, measure_memory_usage): + + set_original_response() + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'test-tag, other-tag'}, + follow_redirects=True + ) + + assert b"Watch added" in res.data + + notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') + notification_form_data = {"notification_urls": notification_url, + "notification_title": "New GROUP TAG ChangeDetection.io Notification - {{watch_url}}", + "notification_body": "BASE URL: {{base_url}}\n" + "Watch URL: {{watch_url}}\n" + "Watch UUID: {{watch_uuid}}\n" + "Watch title: {{watch_title}}\n" + "Watch tag: {{watch_tag}}\n" + "Preview: {{preview_url}}\n" + "Diff URL: {{diff_url}}\n" + "Snapshot: {{current_snapshot}}\n" + "Diff: {{diff}}\n" + "Diff Added: {{diff_added}}\n" + "Diff Removed: {{diff_removed}}\n" + "Diff Full: {{diff_full}}\n" + "Diff as Patch: {{diff_patch}}\n" + ":-)", + "notification_screenshot": True, + "notification_format": "Text", + "title": "test-tag"} + + res = client.post( + url_for("tags.form_tag_edit_submit", uuid=get_UUID_for_tag_name(client, name="test-tag")), + data=notification_form_data, + follow_redirects=True + ) + assert b"Updated" in res.data + + wait_for_all_checks(client) + + set_modified_response() + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + time.sleep(3) + + assert os.path.isfile("test-datastore/notification.txt") + + # Verify what was sent as a notification, this file should exist + with open("test-datastore/notification.txt", "r") as f: + notification_submission = f.read() + os.unlink("test-datastore/notification.txt") + + # Did we see the URL that had a change, in the notification? + # Diff was correctly executed + assert test_url in notification_submission + assert ':-)' in notification_submission + assert "Diff Full: Some initial text" in notification_submission + assert "New GROUP TAG ChangeDetection.io" in notification_submission + assert "test-tag" in notification_submission + assert "other-tag" in notification_submission + + #@todo Test that multiple notifications fired + #@todo Test that each of multiple notifications with different settings + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_limit_tag_ui(client, live_server, measure_memory_usage): + + + test_url = url_for('test_endpoint', _external=True) + urls=[] + + for i in range(20): + urls.append(test_url+"?x="+str(i)+" test-tag") + + for i in range(20): + urls.append(test_url+"?non-grouped="+str(i)) + + res = client.post( + url_for("imports.import_page"), + data={"urls": "\r\n".join(urls)}, + follow_redirects=True + ) + + assert b"40 Imported" in res.data + + res = client.get(url_for("watchlist.index")) + assert b'test-tag' in res.data + + # All should be here + assert res.data.count(b'processor-text_json_diff') == 40 + + tag_uuid = get_UUID_for_tag_name(client, name="test-tag") + + res = client.get(url_for("watchlist.index", tag=tag_uuid)) + + # Just a subset should be here + assert b'test-tag' in res.data + assert res.data.count(b'processor-text_json_diff') == 20 + assert b"object at" not in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + res = client.get(url_for("tags.delete_all"), follow_redirects=True) + assert b'All tags deleted' in res.data + +def test_clone_tag_on_import(client, live_server, measure_memory_usage): + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url + " test-tag, another-tag\r\n"}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + res = client.get(url_for("watchlist.index")) + assert b'test-tag' in res.data + assert b'another-tag' in res.data + + watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + res = client.get(url_for("ui.form_clone", uuid=watch_uuid), follow_redirects=True) + + assert b'Cloned' in res.data + res = client.get(url_for("watchlist.index")) + # 2 times plus the top link to tag + assert res.data.count(b'test-tag') == 3 + assert res.data.count(b'another-tag') == 3 + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_clone_tag_on_quickwatchform_add(client, live_server, measure_memory_usage): + + + test_url = url_for('test_endpoint', _external=True) + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": ' test-tag, another-tag '}, + follow_redirects=True + ) + + assert b"Watch added" in res.data + + res = client.get(url_for("watchlist.index")) + assert b'test-tag' in res.data + assert b'another-tag' in res.data + + watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + res = client.get(url_for("ui.form_clone", uuid=watch_uuid), follow_redirects=True) + assert b'Cloned' in res.data + + res = client.get(url_for("watchlist.index")) + # 2 times plus the top link to tag + assert res.data.count(b'test-tag') == 3 + assert res.data.count(b'another-tag') == 3 + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + res = client.get(url_for("tags.delete_all"), follow_redirects=True) + assert b'All tags deleted' in res.data + +def test_order_of_filters_tag_filter_and_watch_filter(client, live_server, measure_memory_usage): + + # Add a tag with some config, import a tag and it should roughly work + res = client.post( + url_for("tags.form_tag_add"), + data={"name": "test-tag-keep-order"}, + follow_redirects=True + ) + assert b"Tag added" in res.data + assert b"test-tag-keep-order" in res.data + tag_filters = [ + '#only-this', # duplicated filters + '#only-this', + '#only-this', + '#only-this', + ] + + res = client.post( + url_for("tags.form_tag_edit_submit", uuid="first"), + data={"name": "test-tag-keep-order", + "include_filters": '\n'.join(tag_filters) }, + follow_redirects=True + ) + assert b"Updated" in res.data + tag_uuid = get_UUID_for_tag_name(client, name="test-tag-keep-order") + res = client.get( + url_for("tags.form_tag_edit", uuid="first") + ) + assert b"#only-this" in res.data + + + d = """ + + Some initial text
    +

    And 1 this

    +
    +

    And 2 this

    +

    And 3 this

    +

    And 4 this

    +

    And 5 this

    +

    And 6 this

    +

    And 7 this

    +

    And 8 this

    +

    And 9 this

    +

    And 10 this

    +

    And 11 this

    +

    And 12 this

    +

    And 13 this

    +

    And 14 this

    +

    And 15 this

    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(d) + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + filters = [ + '/html/body/p[3]', + '/html/body/p[4]', + '/html/body/p[5]', + '/html/body/p[6]', + '/html/body/p[7]', + '/html/body/p[8]', + '/html/body/p[9]', + '/html/body/p[10]', + '/html/body/p[11]', + '/html/body/p[12]', + '/html/body/p[13]', # duplicated tags + '/html/body/p[13]', + '/html/body/p[13]', + '/html/body/p[13]', + '/html/body/p[13]', + '/html/body/p[14]', + ] + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": '\n'.join(filters), + "url": test_url, + "tags": "test-tag-keep-order", + "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b"And 1 this" in res.data # test-tag-keep-order + + a_tag_filter_check = b'And 1 this' #'#only-this' of tag_filters + # check there is no duplication of tag_filters + assert res.data.count(a_tag_filter_check) == 1, f"duplicated filters didn't removed {res.data.count(a_tag_filter_check)} of {a_tag_filter_check} in {res.data=}" + + a_filter_check = b"And 13 this" # '/html/body/p[13]' + # check there is no duplication of filters + assert res.data.count(a_filter_check) == 1, f"duplicated filters didn't removed. {res.data.count(a_filter_check)} of {a_filter_check} in {res.data=}" + + a_filter_check_not_include = b"And 2 this" # '/html/body/p[2]' + assert a_filter_check_not_include not in res.data + + checklist = [ + b"And 3 this", + b"And 4 this", + b"And 5 this", + b"And 6 this", + b"And 7 this", + b"And 8 this", + b"And 9 this", + b"And 10 this", + b"And 11 this", + b"And 12 this", + b"And 13 this", + b"And 14 this", + b"And 1 this", # result of filter from tag. + ] + # check whether everything a user requested is there + for test in checklist: + assert test in res.data + + # check whether everything a user requested is in order of filters. + n = 0 + for test in checklist: + t_index = res.data[n:].find(test) + # if the text is not searched, return -1. + assert t_index >= 0, f"""failed because {test=} not in {res.data[n:]=} +##################### +Looks like some feature changed the order of result of filters. +##################### +the {test} appeared before. {test in res.data[:n]=} +{res.data[:n]=} + """ + n += t_index + len(test) + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test_history_consistency.py b/changedetectionio/tests/test_history_consistency.py new file mode 100644 index 0000000000000000000000000000000000000000..fbcfba4d140c082f52a8e8103a1121312acb62cb --- /dev/null +++ b/changedetectionio/tests/test_history_consistency.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 + +import time +import os +import json +import logging +from flask import url_for +from .util import live_server_setup, wait_for_all_checks +from urllib.parse import urlparse, parse_qs + +def test_consistent_history(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + workers = int(os.getenv("FETCH_WORKERS", 10)) + r = range(1, 10+workers) + + for one in r: + test_url = url_for('test_endpoint', content_type="text/html", content=str(one), _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + wait_for_all_checks(client) + + # Essentially just triggers the DB write/update + res = client.post( + url_for("settings.settings_page"), + data={"application-empty_pages_are_a_change": "", + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Settings updated." in res.data + + + time.sleep(2) + + json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json') + + json_obj = None + with open(json_db_file, 'r') as f: + json_obj = json.load(f) + + # assert the right amount of watches was found in the JSON + assert len(json_obj['watching']) == len(r), "Correct number of watches was found in the JSON" + i=0 + # each one should have a history.txt containing just one line + for w in json_obj['watching'].keys(): + i+=1 + history_txt_index_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, 'history.txt') + assert os.path.isfile(history_txt_index_file), f"History.txt should exist where I expect it at {history_txt_index_file}" + + # Same like in model.Watch + with open(history_txt_index_file, "r") as f: + tmp_history = dict(i.strip().split(',', 2) for i in f.readlines()) + assert len(tmp_history) == 1, "History.txt should contain 1 line" + + # Should be two files,. the history.txt , and the snapshot.txt + files_in_watch_dir = os.listdir(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w)) + + # Find the snapshot one + for fname in files_in_watch_dir: + if fname != 'history.txt' and 'html' not in fname: + # contents should match what we requested as content returned from the test url + with open(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname), 'r') as snapshot_f: + contents = snapshot_f.read() + watch_url = json_obj['watching'][w]['url'] + u = urlparse(watch_url) + q = parse_qs(u[4]) + assert q['content'][0] == contents.strip(), f"Snapshot file {fname} should contain {q['content'][0]}" + + + + assert len(files_in_watch_dir) == 3, "Should be just three files in the dir, html.br snapshot, history.txt and the extracted text snapshot" + + json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json') + with open(json_db_file, 'r') as f: + assert '"default"' not in f.read(), "'default' probably shouldnt be here, it came from when the 'default' Watch vars were accidently being saved" diff --git a/changedetectionio/tests/test_html_to_text.py b/changedetectionio/tests/test_html_to_text.py new file mode 100644 index 0000000000000000000000000000000000000000..b1bc3ca3f6202dc2feb0027e4f1a15bb8fdc8cba --- /dev/null +++ b/changedetectionio/tests/test_html_to_text.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Test suite for the method to extract text from an html string""" +from ..html_tools import html_to_text + + +def test_html_to_text_func(): + test_html = """ + + Some initial text
    +

    Which is across multiple lines

    + More Text +
    + So let's see what happens.
    + Even More Text + + + """ + + # extract text, with 'render_anchor_tag_content' set to False + text_content = html_to_text(test_html, render_anchor_tag_content=False) + + no_links_text = \ + "Some initial text\n\nWhich is across multiple " \ + "lines\n\nMore Text\nSo let's see what happens.\nEven More Text" + + # check that no links are in the extracted text + assert text_content == no_links_text + + # extract text, with 'render_anchor_tag_content' set to True + text_content = html_to_text(test_html, render_anchor_tag_content=True) + + links_text = \ + "Some initial text\n\nWhich is across multiple lines\n\n[ More Text " \ + "](/first_link)\nSo let's see what happens.\n[ Even More Text ]" \ + "(second_link.com)" + + # check that links are present in the extracted text + assert text_content == links_text diff --git a/changedetectionio/tests/test_ignore.py b/changedetectionio/tests/test_ignore.py new file mode 100644 index 0000000000000000000000000000000000000000..985e58b8bedb432cab0a7faa1cda2114f040e52f --- /dev/null +++ b/changedetectionio/tests/test_ignore.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks +from changedetectionio import html_tools +from . util import extract_UUID_from_client + +def set_original_ignore_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +

    oh yeah 456

    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def test_ignore(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + set_original_ignore_response() + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + # use the highlighter endpoint + res = client.post( + url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid), + data={"mode": 'digit-regex', 'selection': 'oh yeah 123'}, + follow_redirects=True + ) + + res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid)) + # should be a regex now + assert b'/oh\ yeah\ \d+/' in res.data + + # Should return a link + assert b'href' in res.data + + # It should not be in the preview anymore + res = client.get(url_for("ui.ui_views.preview_page", uuid=uuid)) + assert b'
    oh yeah 456' not in res.data + + # Should be in base.html + assert b'csrftoken' in res.data + diff --git a/changedetectionio/tests/test_ignore_regex_text.py b/changedetectionio/tests/test_ignore_regex_text.py new file mode 100644 index 0000000000000000000000000000000000000000..847a3e745cdfd01ab1e7f538fa5957ce79bcdb7f --- /dev/null +++ b/changedetectionio/tests/test_ignore_regex_text.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 + +from . util import live_server_setup +from changedetectionio import html_tools + + + +# Unit test of the stripper +# Always we are dealing in utf-8 +def test_strip_regex_text_func(): + test_content = """ + but sometimes we want to remove the lines. + + but 1 lines + skip 5 lines + really? yes man +#/not this tries weirdly formed regex or just strings starting with / +/not this + but including 1234 lines + igNORe-cAse text we dont want to keep + but not always.""" + + + ignore_lines = [ + "sometimes", + "/\s\d{2,3}\s/", + "/ignore-case text/", + "really?", + "/skip \d lines/i", + "/not" + ] + + stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines) + assert "but 1 lines" in stripped_content + assert "igNORe-cAse text" not in stripped_content + assert "but 1234 lines" not in stripped_content + assert "really" not in stripped_content + assert "not this" not in stripped_content + + # Check line number reporting + stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines, mode="line numbers") + assert stripped_content == [2, 5, 6, 7, 8, 10] + + stripped_content = html_tools.strip_ignore_text(test_content, ['/but 1.+5 lines/s']) + assert "but 1 lines" not in stripped_content + assert "skip 5 lines" not in stripped_content + + stripped_content = html_tools.strip_ignore_text(test_content, ['/but 1.+5 lines/s'], mode="line numbers") + assert stripped_content == [4, 5] + + stripped_content = html_tools.strip_ignore_text(test_content, ['/.+/s']) + assert stripped_content == "" + + stripped_content = html_tools.strip_ignore_text(test_content, ['/.+/s'], mode="line numbers") + assert stripped_content == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] + + stripped_content = html_tools.strip_ignore_text(test_content, ['/^.+but.+\\n.+lines$/m']) + assert "but 1 lines" not in stripped_content + assert "skip 5 lines" not in stripped_content + + stripped_content = html_tools.strip_ignore_text(test_content, ['/^.+but.+\\n.+lines$/m'], mode="line numbers") + assert stripped_content == [4, 5] + + stripped_content = html_tools.strip_ignore_text(test_content, ['/^.+?\.$/m']) + assert "but sometimes we want to remove the lines." not in stripped_content + assert "but not always." not in stripped_content + + stripped_content = html_tools.strip_ignore_text(test_content, ['/^.+?\.$/m'], mode="line numbers") + assert stripped_content == [2, 11] + + stripped_content = html_tools.strip_ignore_text(test_content, ['/but.+?but/ms']) + assert "but sometimes we want to remove the lines." not in stripped_content + assert "but 1 lines" not in stripped_content + assert "but 1234 lines" not in stripped_content + assert "igNORe-cAse text we dont want to keep" not in stripped_content + assert "but not always." not in stripped_content + + stripped_content = html_tools.strip_ignore_text(test_content, ['/but.+?but/ms'], mode="line numbers") + assert stripped_content == [2, 3, 4, 9, 10, 11] + + stripped_content = html_tools.strip_ignore_text("\n\ntext\n\ntext\n\n", ['/^$/ms'], mode="line numbers") + assert stripped_content == [1, 2, 4, 6] + + # Check that linefeeds are preserved when there are is no matching ignores + content = "some text\n\nand other text\n" + stripped_content = html_tools.strip_ignore_text(content, ignore_lines) + assert content == stripped_content diff --git a/changedetectionio/tests/test_ignore_text.py b/changedetectionio/tests/test_ignore_text.py new file mode 100644 index 0000000000000000000000000000000000000000..19fe230307b258d130273d3a279a031c07b68f83 --- /dev/null +++ b/changedetectionio/tests/test_ignore_text.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks +from changedetectionio import html_tools + + + +# Unit test of the stripper +# Always we are dealing in utf-8 +def test_strip_text_func(): + test_content = """ + Some content + is listed here + + but sometimes we want to remove the lines. + + but not always.""" + + ignore_lines = ["sometimes"] + + stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines) + assert "sometimes" not in stripped_content + assert "Some content" in stripped_content + + # Check that line feeds dont get chewed up when something is found + test_content = "Some initial text\n\nWhich is across multiple lines\n\nZZZZz\n\n\nSo let's see what happens." + ignore = ['something irrelevent but just to check', 'XXXXX', 'YYYYY', 'ZZZZZ'] + + stripped_content = html_tools.strip_ignore_text(test_content, ignore) + assert stripped_content == "Some initial text\n\nWhich is across multiple lines\n\n\n\nSo let's see what happens." + +def set_original_ignore_response(ver_stamp="123"): + test_return_data = f""" + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def set_modified_original_ignore_response(ver_stamp="123"): + test_return_data = f""" + + Some NEW nice initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + +

    new ignore stuff

    +

    blah

    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +# Is the same but includes ZZZZZ, 'ZZZZZ' is the last line in ignore_text +def set_modified_ignore_response(ver_stamp="123"): + test_return_data = f""" + + Some initial text
    +

    Which is across multiple lines

    +

    ZZZZz

    +
    + So let's see what happens.
    + + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +# Ignore text now just removes it entirely, is a LOT more simpler code this way + +def test_check_ignore_text_functionality(client, live_server, measure_memory_usage): + + # Use a mix of case in ZzZ to prove it works case-insensitive. + ignore_text = "XXXXX\r\nYYYYY\r\nzZzZZ\r\nnew ignore stuff" + set_original_ignore_response() + + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"ignore_text": ignore_text, "url": test_url, 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + # Check it saved + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + ) + assert bytes(ignore_text.encode('utf-8')) in res.data + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + assert b'/test-endpoint' in res.data + + # Make a change + set_modified_ignore_response() + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + assert b'/test-endpoint' in res.data + + + + # Just to be sure.. set a regular modified change.. + set_modified_original_ignore_response() + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + res = client.get(url_for("ui.ui_views.preview_page", uuid="first")) + + # SHOULD BE be in the preview, it was added in set_modified_original_ignore_response() + # and we have "new ignore stuff" in ignore_text + # it is only ignored, it is not removed (it will be highlighted too) + assert b'new ignore stuff' in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +# When adding some ignore text, it should not trigger a change, even if something else on that line changes +def _run_test_global_ignore(client, as_source=False, extra_ignore=""): + ignore_text = "XXXXX\r\nYYYYY\r\nZZZZZ\r\n"+extra_ignore + + set_original_ignore_response() + + # Goto the settings page, add our ignore text + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-ignore_whitespace": "y", + "application-global_ignore_text": ignore_text, + 'application-fetch_backend': "html_requests" + }, + follow_redirects=True + ) + assert b"Settings updated." in res.data + + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + if as_source: + # Switch to source mode so we can test that too! + test_url = "source:"+test_url + + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + #Adding some ignore text should not trigger a change + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"ignore_text": "something irrelevent but just to check", "url": test_url, 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + # Check it saved + res = client.get( + url_for("settings.settings_page"), + ) + + for i in ignore_text.splitlines(): + assert bytes(i.encode('utf-8')) in res.data + + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + # It should report nothing found (no new 'unviewed' class), adding random ignore text should not cause a change + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + assert b'/test-endpoint' in res.data +##### + + # Make a change which includes the ignore text, it should be ignored and no 'change' triggered + # It adds text with "ZZZZzzzz" and "ZZZZ" is in the ignore list + # And tweaks the ver_stamp which should be picked up by global regex ignore + set_modified_ignore_response(ver_stamp=time.time()) + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + + assert b'unviewed' not in res.data + assert b'/test-endpoint' in res.data + + # Just to be sure.. set a regular modified change that will trigger it + set_modified_original_ignore_response() + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_check_global_ignore_text_functionality(client, live_server): + + _run_test_global_ignore(client, as_source=False) + +def test_check_global_ignore_text_functionality_as_source(client, live_server): + + _run_test_global_ignore(client, as_source=True, extra_ignore='/\?v=\d/') diff --git a/changedetectionio/tests/test_ignorehyperlinks.py b/changedetectionio/tests/test_ignorehyperlinks.py new file mode 100644 index 0000000000000000000000000000000000000000..5df8f9ae44fe6014b67ea8d44177b8a2fecf2478 --- /dev/null +++ b/changedetectionio/tests/test_ignorehyperlinks.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Test suite for the render/not render anchor tag content functionality""" + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + + + + +def set_original_ignore_response(): + test_return_data = """ + + Some initial text
    + Some More Text +
    + So let's see what happens.
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +# Should be the same as set_original_ignore_response() but with a different +# link +def set_modified_ignore_response(): + test_return_data = """ + + Some initial text
    + Some More Text +
    + So let's see what happens.
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + +def test_render_anchor_tag_content_true(client, live_server, measure_memory_usage): + """Testing that the link changes are detected when + render_anchor_tag_content setting is set to true""" + sleep_time_for_fetch_thread = 3 + + # Give the endpoint time to spin up + time.sleep(1) + + # set original html text + set_original_ignore_response() + + # Goto the settings page, choose to ignore links (dont select/send "application-render_anchor_tag_content") + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-fetch_backend": "html_requests", + }, + follow_redirects=True, + ) + assert b"Settings updated." in res.data + + # Add our URL to the import page + test_url = url_for("test_endpoint", _external=True) + res = client.post( + url_for("imports.import_page"), data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + wait_for_all_checks(client) + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # set a new html text with a modified link + set_modified_ignore_response() + wait_for_all_checks(client) + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + wait_for_all_checks(client) + + # We should not see the rendered anchor tag + res = client.get(url_for("ui.ui_views.preview_page", uuid="first")) + assert '(/modified_link)' not in res.data.decode() + + # Goto the settings page, ENABLE render anchor tag + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-render_anchor_tag_content": "true", + "application-fetch_backend": "html_requests", + }, + follow_redirects=True, + ) + assert b"Settings updated." in res.data + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # Give the thread time to pick it up + wait_for_all_checks(client) + + + + # check that the anchor tag content is rendered + res = client.get(url_for("ui.ui_views.preview_page", uuid="first")) + assert '(/modified_link)' in res.data.decode() + + # since the link has changed, and we chose to render anchor tag content, + # we should detect a change (new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b"unviewed" in res.data + assert b"/test-endpoint" in res.data + + # Cleanup everything + res = client.get(url_for("ui.form_delete", uuid="all"), + follow_redirects=True) + assert b'Deleted' in res.data + diff --git a/changedetectionio/tests/test_ignorestatuscode.py b/changedetectionio/tests/test_ignorestatuscode.py new file mode 100644 index 0000000000000000000000000000000000000000..a28e8996a078521825a1bc33a8696ea5d5933dbe --- /dev/null +++ b/changedetectionio/tests/test_ignorestatuscode.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + + + + + +def set_original_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def set_some_changed_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines, and a new thing too.

    +
    + So let's see what happens.
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def test_normal_page_check_works_with_ignore_status_code(client, live_server, measure_memory_usage): + + + # Give the endpoint time to spin up + time.sleep(1) + + set_original_response() + + # Goto the settings page, add our ignore text + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-ignore_status_codes": "y", + 'application-fetch_backend': "html_requests" + }, + follow_redirects=True + ) + assert b"Settings updated." in res.data + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + wait_for_all_checks(client) + + set_some_changed_response() + wait_for_all_checks(client) + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + assert b'/test-endpoint' in res.data + + +# Tests the whole stack works with staus codes ignored +def test_403_page_check_works_with_ignore_status_code(client, live_server, measure_memory_usage): + sleep_time_for_fetch_thread = 3 + + set_original_response() + + # Give the endpoint time to spin up + time.sleep(1) + + # Add our URL to the import page + test_url = url_for('test_endpoint', status_code=403, _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + time.sleep(sleep_time_for_fetch_thread) + + # Goto the edit page, check our ignore option + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"ignore_status_codes": "y", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # Make a change + set_some_changed_response() + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should have 'unviewed' still + # Because it should be looking at only that 'sametext' id + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + diff --git a/changedetectionio/tests/test_ignorewhitespace.py b/changedetectionio/tests/test_ignorewhitespace.py new file mode 100644 index 0000000000000000000000000000000000000000..93fa94b2e907a5e0cda17c7da910caed7f7ce89b --- /dev/null +++ b/changedetectionio/tests/test_ignorewhitespace.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from . util import live_server_setup + + + + +# Should be the same as set_original_ignore_response() but with a little more whitespacing +def set_original_ignore_response_but_with_whitespace(): + test_return_data = """ + + Some initial text
    +

    + + + Which is across multiple lines

    +
    +
    + + So let's see what happens.
    + + + + + + """ + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def set_original_ignore_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + + +# If there was only a change in the whitespacing, then we shouldnt have a change detected +def test_check_ignore_whitespace(client, live_server, measure_memory_usage): + sleep_time_for_fetch_thread = 3 + + # Give the endpoint time to spin up + time.sleep(1) + + set_original_ignore_response() + + # Goto the settings page, add our ignore text + res = client.post( + url_for("settings.settings_page"), + data={ + "requests-time_between_check-minutes": 180, + "application-ignore_whitespace": "y", + "application-fetch_backend": "html_requests" + }, + follow_redirects=True + ) + assert b"Settings updated." in res.data + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + time.sleep(sleep_time_for_fetch_thread) + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + set_original_ignore_response_but_with_whitespace() + time.sleep(sleep_time_for_fetch_thread) + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # Give the thread time to pick it up + time.sleep(sleep_time_for_fetch_thread) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + assert b'/test-endpoint' in res.data diff --git a/changedetectionio/tests/test_import.py b/changedetectionio/tests/test_import.py new file mode 100644 index 0000000000000000000000000000000000000000..899ff1bac26eaa9326ab4feb006363380f82b121 --- /dev/null +++ b/changedetectionio/tests/test_import.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +import io +import os +import time + +from flask import url_for + +from .util import live_server_setup, wait_for_all_checks + + +# def test_setup(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + +def test_import(client, live_server, measure_memory_usage): + # Give the endpoint time to spin up + wait_for_all_checks(client) + + res = client.post( + url_for("imports.import_page"), + data={ + "distill-io": "", + "urls": """https://example.com +https://example.com tag1 +https://example.com tag1, other tag""" + }, + follow_redirects=True, + ) + assert b"3 Imported" in res.data + assert b"tag1" in res.data + assert b"other tag" in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + # Clear flask alerts + res = client.get( url_for("watchlist.index")) + res = client.get( url_for("watchlist.index")) + +def xtest_import_skip_url(client, live_server, measure_memory_usage): + + + # Give the endpoint time to spin up + time.sleep(1) + + res = client.post( + url_for("imports.import_page"), + data={ + "distill-io": "", + "urls": """https://example.com +:ht000000broken +""" + }, + follow_redirects=True, + ) + assert b"1 Imported" in res.data + assert b"ht000000broken" in res.data + assert b"1 Skipped" in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + # Clear flask alerts + res = client.get( url_for("watchlist.index")) + +def test_import_distillio(client, live_server, measure_memory_usage): + + distill_data=''' +{ + "client": { + "local": 1 + }, + "data": [ + { + "name": "Unraid | News", + "uri": "https://unraid.net/blog", + "config": "{\\"selections\\":[{\\"frames\\":[{\\"index\\":0,\\"excludes\\":[],\\"includes\\":[{\\"type\\":\\"xpath\\",\\"expr\\":\\"(//div[@id='App']/div[contains(@class,'flex')]/main[contains(@class,'relative')]/section[contains(@class,'relative')]/div[@class='container']/div[contains(@class,'flex')]/div[contains(@class,'w-full')])[1]\\"}]}],\\"dynamic\\":true,\\"delay\\":2}],\\"ignoreEmptyText\\":true,\\"includeStyle\\":false,\\"dataAttr\\":\\"text\\"}", + "tags": ["nice stuff", "nerd-news"], + "content_type": 2, + "state": 40, + "schedule": "{\\"type\\":\\"INTERVAL\\",\\"params\\":{\\"interval\\":4447}}", + "ts": "2022-03-27T15:51:15.667Z" + } + ] +} + +''' + + # Give the endpoint time to spin up + time.sleep(1) + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + res = client.post( + url_for("imports.import_page"), + data={ + "distill-io": distill_data, + "urls" : '' + }, + follow_redirects=True, + ) + + + assert b"Unable to read JSON file, was it broken?" not in res.data + assert b"1 Imported from Distill.io" in res.data + + res = client.get( url_for("ui.ui_edit.edit_page", uuid="first")) + + assert b"https://unraid.net/blog" in res.data + assert b"Unraid | News" in res.data + + + # flask/wtforms should recode this, check we see it + # wtforms encodes it like id=' ,but html.escape makes it like id=' + # - so just check it manually :( + #import json + #import html + #d = json.loads(distill_data) + # embedded_d=json.loads(d['data'][0]['config']) + # x=html.escape(embedded_d['selections'][0]['frames'][0]['includes'][0]['expr']).encode('utf-8') + assert b"xpath:(//div[@id='App']/div[contains(@class,'flex')]/main[contains(@class,'relative')]/section[contains(@class,'relative')]/div[@class='container']/div[contains(@class,'flex')]/div[contains(@class,'w-full')])[1]" in res.data + + # did the tags work? + res = client.get( url_for("watchlist.index")) + + # check tags + assert b"nice stuff" in res.data + assert b"nerd-news" in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + # Clear flask alerts + res = client.get(url_for("watchlist.index")) + +def test_import_custom_xlsx(client, live_server, measure_memory_usage): + """Test can upload a excel spreadsheet and the watches are created correctly""" + + + + dirname = os.path.dirname(__file__) + filename = os.path.join(dirname, 'import/spreadsheet.xlsx') + with open(filename, 'rb') as f: + + data= { + 'file_mapping': 'custom', + 'custom_xlsx[col_0]': '1', + 'custom_xlsx[col_1]': '3', + 'custom_xlsx[col_2]': '5', + 'custom_xlsx[col_3]': '4', + 'custom_xlsx[col_type_0]': 'title', + 'custom_xlsx[col_type_1]': 'url', + 'custom_xlsx[col_type_2]': 'include_filters', + 'custom_xlsx[col_type_3]': 'interval_minutes', + 'xlsx_file': (io.BytesIO(f.read()), 'spreadsheet.xlsx') + } + + res = client.post( + url_for("imports.import_page"), + data=data, + follow_redirects=True, + ) + + assert b'4 imported from custom .xlsx' in res.data + # Because this row was actually just a header with no usable URL, we should get an error + assert b'Error processing row number 1' in res.data + + res = client.get( + url_for("watchlist.index") + ) + + assert b'Somesite results ABC' in res.data + assert b'City news results' in res.data + + # Just find one to check over + for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items(): + if watch.get('title') == 'Somesite results ABC': + filters = watch.get('include_filters') + assert filters[0] == '/html[1]/body[1]/div[4]/div[1]/div[1]/div[1]||//*[@id=\'content\']/div[3]/div[1]/div[1]||//*[@id=\'content\']/div[1]' + assert watch.get('time_between_check') == {'weeks': 0, 'days': 1, 'hours': 6, 'minutes': 24, 'seconds': 0} + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_import_watchete_xlsx(client, live_server, measure_memory_usage): + """Test can upload a excel spreadsheet and the watches are created correctly""" + + + dirname = os.path.dirname(__file__) + filename = os.path.join(dirname, 'import/spreadsheet.xlsx') + with open(filename, 'rb') as f: + + data= { + 'file_mapping': 'wachete', + 'xlsx_file': (io.BytesIO(f.read()), 'spreadsheet.xlsx') + } + + res = client.post( + url_for("imports.import_page"), + data=data, + follow_redirects=True, + ) + + assert b'4 imported from Wachete .xlsx' in res.data + + res = client.get( + url_for("watchlist.index") + ) + + assert b'Somesite results ABC' in res.data + assert b'City news results' in res.data + + # Just find one to check over + for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items(): + if watch.get('title') == 'Somesite results ABC': + filters = watch.get('include_filters') + assert filters[0] == '/html[1]/body[1]/div[4]/div[1]/div[1]/div[1]||//*[@id=\'content\']/div[3]/div[1]/div[1]||//*[@id=\'content\']/div[1]' + assert watch.get('time_between_check') == {'weeks': 0, 'days': 1, 'hours': 6, 'minutes': 24, 'seconds': 0} + assert watch.get('fetch_backend') == 'html_requests' # Has inactive 'dynamic wachet' + + if watch.get('title') == 'JS website': + assert watch.get('fetch_backend') == 'html_webdriver' # Has active 'dynamic wachet' + + if watch.get('title') == 'system default website': + assert watch.get('fetch_backend') == 'system' # uses default if blank + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test_jinja2.py b/changedetectionio/tests/test_jinja2.py new file mode 100644 index 0000000000000000000000000000000000000000..71152943f1723b9f99a35ed07b2d3706364f5edf --- /dev/null +++ b/changedetectionio/tests/test_jinja2.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + + +# def test_setup(client, live_server, measure_memory_usage): + # # live_server_setup(live_server) # Setup on conftest per function + +# If there was only a change in the whitespacing, then we shouldnt have a change detected +def test_jinja2_in_url_query(client, live_server, measure_memory_usage): + + + # Add our URL to the import page + test_url = url_for('test_return_query', _external=True) + + # because url_for() will URL-encode the var, but we dont here + full_url = "{}?{}".format(test_url, + "date={% now 'Europe/Berlin', '%Y' %}.{% now 'Europe/Berlin', '%m' %}.{% now 'Europe/Berlin', '%d' %}", ) + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": full_url, "tags": "test"}, + follow_redirects=True + ) + assert b"Watch added" in res.data + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + assert b'date=2' in res.data + +# https://techtonics.medium.com/secure-templating-with-jinja2-understanding-ssti-and-jinja2-sandbox-environment-b956edd60456 +def test_jinja2_security_url_query(client, live_server, measure_memory_usage): + + + # Add our URL to the import page + test_url = url_for('test_return_query', _external=True) + + # because url_for() will URL-encode the var, but we dont here + full_url = "{}?{}".format(test_url, + "date={{ ''.__class__.__mro__[1].__subclasses__()}}", ) + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": full_url, "tags": "test"}, + follow_redirects=True + ) + assert b"Watch added" in res.data + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'is invalid and cannot be used' in res.data + # Some of the spewed output from the subclasses + assert b'dict_values' not in res.data diff --git a/changedetectionio/tests/test_jsonpath_jq_selector.py b/changedetectionio/tests/test_jsonpath_jq_selector.py new file mode 100644 index 0000000000000000000000000000000000000000..380e6dea9e0be2e040d028c90cb2fc7faa6c9b70 --- /dev/null +++ b/changedetectionio/tests/test_jsonpath_jq_selector.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +import time +from flask import url_for, escape +from . util import live_server_setup, wait_for_all_checks +import pytest +jq_support = True + +try: + import jq +except ModuleNotFoundError: + jq_support = False + + + +def test_unittest_inline_html_extract(): + # So lets pretend that the JSON we want is inside some HTML + content=""" + + + food and stuff and more + + + + +and it can also be repeated + +

    ok

    + + + + """ + from .. import html_tools + + # See that we can find the second ', + # LD JSON + '', + '' + # Microdata + '

    Example Product

    This is a sample product description.

    Price: $$$PRICE$$

    ' +] + +out_of_stock_props = [ + # out of stock AND contains multiples + '' +] + +def set_original_response(props_markup='', price="121.95"): + + props_markup=props_markup.replace('$$PRICE$$', price) + test_return_data = f""" + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    price: ${price}
    + {props_markup} + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + time.sleep(1) + return None + + + + +# def test_setup(client, live_server): + + # live_server_setup(live_server) # Setup on conftest per function + +def test_restock_itemprop_basic(client, live_server): + + + + test_url = url_for('test_endpoint', _external=True) + + # By default it should enable ('in_stock_processing') == 'all_changes' + + for p in instock_props: + set_original_response(props_markup=p) + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, + follow_redirects=True + ) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'more than one price detected' not in res.data + assert b'has-restock-info' in res.data + assert b' in-stock' in res.data + assert b' not-in-stock' not in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + + for p in out_of_stock_props: + set_original_response(props_markup=p) + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'processor': 'restock_diff'}, + follow_redirects=True + ) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + + assert b'has-restock-info not-in-stock' in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_itemprop_price_change(client, live_server): + + + # Out of the box 'Follow price changes' should be ON + test_url = url_for('test_endpoint', _external=True) + + set_original_response(props_markup=instock_props[0], price="190.95") + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, + follow_redirects=True + ) + + # A change in price, should trigger a change by default + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'190.95' in res.data + + # basic price change, look for notification + set_original_response(props_markup=instock_props[0], price='180.45') + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'180.45' in res.data + assert b'unviewed' in res.data + client.get(url_for("ui.mark_all_viewed"), follow_redirects=True) + time.sleep(0.2) + + + # turning off price change trigger, but it should show the new price, with no change notification + set_original_response(props_markup=instock_props[0], price='120.45') + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"restock_settings-follow_price_changes": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'120.45' in res.data + assert b'unviewed' not in res.data + + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def _run_test_minmax_limit(client, extra_watch_edit_form): + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + test_url = url_for('test_endpoint', _external=True) + + set_original_response(props_markup=instock_props[0], price="950.95") + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, + follow_redirects=True + ) + wait_for_all_checks(client) + + data = { + "tags": "", + "url": test_url, + "headers": "", + "time_between_check-hours": 5, + 'fetch_backend': "html_requests" + } + data.update(extra_watch_edit_form) + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data=data, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + client.get(url_for("ui.mark_all_viewed")) + + # price changed to something greater than min (900), BUT less than max (1100).. should be no change + set_original_response(props_markup=instock_props[0], price='1000.45') + client.get(url_for("ui.form_watch_checknow")) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + + assert b'more than one price detected' not in res.data + # BUT the new price should show, even tho its within limits + assert b'1,000.45' or b'1000.45' in res.data #depending on locale + assert b'unviewed' not in res.data + + # price changed to something LESS than min (900), SHOULD be a change + set_original_response(props_markup=instock_props[0], price='890.45') + + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'890.45' in res.data + assert b'unviewed' in res.data + + client.get(url_for("ui.mark_all_viewed")) + + + # 2715 - Price detection (once it crosses the "lower" threshold) again with a lower price - should trigger again! + set_original_response(props_markup=instock_props[0], price='820.45') + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'820.45' in res.data + assert b'unviewed' in res.data + client.get(url_for("ui.mark_all_viewed")) + + # price changed to something MORE than max (1100.10), SHOULD be a change + set_original_response(props_markup=instock_props[0], price='1890.45') + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + # Depending on the LOCALE it may be either of these (generally for US/default/etc) + assert b'1,890.45' in res.data or b'1890.45' in res.data + assert b'unviewed' in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_restock_itemprop_minmax(client, live_server): + + extras = { + "restock_settings-follow_price_changes": "y", + "restock_settings-price_change_min": 900.0, + "restock_settings-price_change_max": 1100.10 + } + _run_test_minmax_limit(client, extra_watch_edit_form=extras) + +def test_restock_itemprop_with_tag(client, live_server): + + + res = client.post( + url_for("tags.form_tag_add"), + data={"name": "test-tag"}, + follow_redirects=True + ) + assert b"Tag added" in res.data + + res = client.post( + url_for("tags.form_tag_edit_submit", uuid="first"), + data={"name": "test-tag", + "restock_settings-follow_price_changes": "y", + "restock_settings-price_change_min": 900.0, + "restock_settings-price_change_max": 1100.10, + "overrides_watch": "y", #overrides_watch should be restock_overrides_watch + }, + follow_redirects=True + ) + + extras = { + "tags": "test-tag" + } + + _run_test_minmax_limit(client, extra_watch_edit_form=extras) + + + +def test_itemprop_percent_threshold(client, live_server): + + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + test_url = url_for('test_endpoint', _external=True) + + set_original_response(props_markup=instock_props[0], price="950.95") + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, + follow_redirects=True + ) + + # A change in price, should trigger a change by default + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"restock_settings-follow_price_changes": "y", + "restock_settings-price_change_threshold_percent": 5.0, + "url": test_url, + "tags": "", + "headers": "", + 'fetch_backend': "html_requests" + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + + # Basic change should not trigger + set_original_response(props_markup=instock_props[0], price='960.45') + client.get(url_for("ui.form_watch_checknow")) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'960.45' in res.data + assert b'unviewed' not in res.data + + # Bigger INCREASE change than the threshold should trigger + set_original_response(props_markup=instock_props[0], price='1960.45') + client.get(url_for("ui.form_watch_checknow")) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'1,960.45' or b'1960.45' in res.data #depending on locale + assert b'unviewed' in res.data + + + # Small decrease should NOT trigger + client.get(url_for("ui.mark_all_viewed")) + set_original_response(props_markup=instock_props[0], price='1950.45') + client.get(url_for("ui.form_watch_checknow")) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'1,950.45' or b'1950.45' in res.data #depending on locale + assert b'unviewed' not in res.data + + + + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + + +def test_change_with_notification_values(client, live_server): + + + if os.path.isfile("test-datastore/notification.txt"): + os.unlink("test-datastore/notification.txt") + + test_url = url_for('test_endpoint', _external=True) + set_original_response(props_markup=instock_props[0], price='960.45') + + notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') + + ###################### + # You must add a type of 'restock_diff' for its tokens to register as valid in the global settings + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, + follow_redirects=True + ) + + # A change in price, should trigger a change by default + wait_for_all_checks(client) + + # Should see new tokens register + res = client.get(url_for("settings.settings_page")) + assert b'{{restock.original_price}}' in res.data + assert b'Original price at first check' in res.data + + ##################### + # Set this up for when we remove the notification from the watch, it should fallback with these details + res = client.post( + url_for("settings.settings_page"), + data={"application-notification_urls": notification_url, + "application-notification_title": "title new price {{restock.price}}", + "application-notification_body": "new price {{restock.price}}", + "application-notification_format": default_notification_format, + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + + # check tag accepts without error + + # Check the watches in these modes add the tokens for validating + assert b"A variable or function is not defined" not in res.data + + assert b"Settings updated." in res.data + + + set_original_response(props_markup=instock_props[0], price='960.45') + # A change in price, should trigger a change by default + set_original_response(props_markup=instock_props[0], price='1950.45') + client.get(url_for("ui.form_watch_checknow")) + wait_for_all_checks(client) + wait_for_notification_endpoint_output() + assert os.path.isfile("test-datastore/notification.txt"), "Notification received" + with open("test-datastore/notification.txt", 'r') as f: + notification = f.read() + assert "new price 1950.45" in notification + assert "title new price 1950.45" in notification + + ## Now test the "SEND TEST NOTIFICATION" is working + os.unlink("test-datastore/notification.txt") + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + res = client.post(url_for("ui.ui_notification.ajax_callback_send_notification_test", watch_uuid=uuid), data={}, follow_redirects=True) + time.sleep(5) + assert os.path.isfile("test-datastore/notification.txt"), "Notification received" + + +def test_data_sanity(client, live_server): + + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + test_url = url_for('test_endpoint', _external=True) + test_url2 = url_for('test_endpoint2', _external=True) + set_original_response(props_markup=instock_props[0], price="950.95") + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, + follow_redirects=True + ) + + + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'950.95' in res.data + + # Check the restock model object doesnt store the value by mistake and used in a new one + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url2, "tags": 'restock tests', 'processor': 'restock_diff'}, + follow_redirects=True + ) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert str(res.data.decode()).count("950.95") == 1, "Price should only show once (for the watch added, no other watches yet)" + + ## different test, check the edit page works on an empty request result + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url2, "tags": 'restock tests', 'processor': 'restock_diff'}, + follow_redirects=True + ) + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first")) + assert test_url2.encode('utf-8') in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +# All examples should give a prive of 666.66 +def test_special_prop_examples(client, live_server): + import glob + + + test_url = url_for('test_endpoint', _external=True) + check_path = os.path.join(os.path.dirname(__file__), "itemprop_test_examples", "*.txt") + files = glob.glob(check_path) + assert files + for test_example_filename in files: + with open(test_example_filename, 'r') as example_f: + with open("test-datastore/endpoint-content.txt", "w") as test_f: + test_f.write(f"{example_f.read()}") + + # Now fetch it and check the price worked + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, + follow_redirects=True + ) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'ception' not in res.data + assert b'155.55' in res.data diff --git a/changedetectionio/tests/test_rss.py b/changedetectionio/tests/test_rss.py new file mode 100644 index 0000000000000000000000000000000000000000..847c9fade4ab70196eb68ee0756fc0c499a71461 --- /dev/null +++ b/changedetectionio/tests/test_rss.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \ + extract_UUID_from_client + + +def set_original_cdata_xml(): + test_return_data = """ + + Gizi + https://test.com + + + + + en + + + <![CDATA[ <img src="https://testsite.com/hacked.jpg"> Hackers can access your computer ]]> + + https://testsite.com/news/12341234234 + +

    The days of Terminator and The Matrix could be closer. But be positive.

    Read more link...

    ]]> +
    + cybernetics + rand corporation + Tue, 17 Oct 2023 15:10:00 GMT + 1850933241 + + + + +
    + + + Some other title + https://testsite.com/news/12341234236 + + Some other description + + +
    +
    + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + + +def set_html_content(content): + test_return_data = f""" + + Some initial text
    +

    {content}

    +
    + So let's see what happens.
    + + + """ + + # Write as UTF-8 encoded bytes + with open("test-datastore/endpoint-content.txt", "wb") as f: + f.write(test_return_data.encode('utf-8')) + +# def test_setup(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + +def test_rss_and_token(client, live_server, measure_memory_usage): + # # live_server_setup(live_server) # Setup on conftest per function + + set_original_response() + rss_token = extract_rss_token_from_UI(client) + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": url_for('test_random_content_endpoint', _external=True)}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + wait_for_all_checks(client) + set_modified_response() + time.sleep(1) + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + # Add our URL to the import page + res = client.get( + url_for("rss.feed", token="bad token", _external=True), + follow_redirects=True + ) + + assert b"Access denied, bad token" in res.data + + res = client.get( + url_for("rss.feed", token=rss_token, _external=True), + follow_redirects=True + ) + assert b"Access denied, bad token" not in res.data + assert b"Random content" in res.data + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + +def test_basic_cdata_rss_markup(client, live_server, measure_memory_usage): + + + set_original_cdata_xml() + + test_url = url_for('test_endpoint', content_type="application/xml", _external=True) + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + assert b'CDATA' not in res.data + assert b' Watch'}, + follow_redirects=True + ) + assert b"Watch added in Paused state, saving will unpause" in res.data + + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + res = client.post( + url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1), + data={ + "include_filters": "//item/title", + "fetch_backend": "html_requests", + "headers": "", + "proxy": "no-proxy", + "tags": "", + "url": test_url, + }, + follow_redirects=True + ) + assert b"unpaused" in res.data + + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + assert b'CDATA' not in res.data + assert b' to

    to be sure title renders + + assert b'Hackers can access your computer' in res.data # Should ONLY be selected by the xpath + assert b'Some other title' in res.data # Should ONLY be selected by the xpath + assert b'The days of Terminator' not in res.data # Should NOT be selected by the xpath + assert b'Some other description' not in res.data # Should NOT be selected by the xpath + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + +def test_rss_bad_chars_breaking(client, live_server): + """This should absolutely trigger the RSS builder to go into worst state mode + + - source: prefix means no html conversion (which kinda filters out the bad stuff) + - Binary data + - Very long so that the saving is performed by Brotli (and decoded back to bytes) + + Otherwise feedgen should support regular unicode + """ + + + with open("test-datastore/endpoint-content.txt", "w") as f: + ten_kb_string = "A" * 10_000 + f.write(ten_kb_string) + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": "source:"+test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Set the bad content + with open("test-datastore/endpoint-content.txt", "w") as f: + jpeg_bytes = "\xff\xd8\xff\xe0\x00\x10XXXXXXXX\x00\x01\x02\x00\x00\x01\x00\x01\x00\x00" # JPEG header + jpeg_bytes += "A" * 10_000 + + f.write(jpeg_bytes) + + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + wait_for_all_checks(client) + rss_token = extract_rss_token_from_UI(client) + + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + i=0 + from loguru import logger + # Because chardet could take a long time + while i<=10: + logger.debug(f"History was {live_server.app.config['DATASTORE'].data['watching'][uuid].history_n}..") + if live_server.app.config['DATASTORE'].data['watching'][uuid].history_n ==2: + break + i+=1 + time.sleep(2) + assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2 + + # Check RSS feed is still working + res = client.get( + url_for("rss.feed", uuid=uuid, token=rss_token), + follow_redirects=False # Important! leave this off! it should not redirect + ) + assert res.status_code == 200 + + #assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2 + #assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2 + + + + + diff --git a/changedetectionio/tests/test_scheduler.py b/changedetectionio/tests/test_scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..51610d60ed9e7acd17599871fc5eb74b87b54889 --- /dev/null +++ b/changedetectionio/tests/test_scheduler.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 + +import time +from datetime import datetime, timezone +from zoneinfo import ZoneInfo +from flask import url_for +from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client + +# def test_setup(client, live_server): + # live_server_setup(live_server) # Setup on conftest per function + +def test_check_basic_scheduler_functionality(client, live_server, measure_memory_usage): + + days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + test_url = url_for('test_random_content_endpoint', _external=True) + + # We use "Pacific/Kiritimati" because its the furthest +14 hours, so it might show up more interesting bugs + # The rest of the actual functionality should be covered in the unit-test unit/test_scheduler.py + ##################### + res = client.post( + url_for("settings.settings_page"), + data={"application-empty_pages_are_a_change": "", + "requests-time_between_check-seconds": 1, + "application-timezone": "Pacific/Kiritimati", # Most Forward Time Zone (UTC+14:00) + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + + res = client.get(url_for("settings.settings_page")) + assert b'Pacific/Kiritimati' in res.data + + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + + # Setup all the days of the weeks using XXX as the placeholder for monday/tuesday/etc + + tpl = { + "time_schedule_limit-XXX-start_time": "00:00", + "time_schedule_limit-XXX-duration-hours": 24, + "time_schedule_limit-XXX-duration-minutes": 0, + "time_schedule_limit-XXX-enabled": '', # All days are turned off + "time_schedule_limit-enabled": 'y', # Scheduler is enabled, all days however are off. + } + + scheduler_data = {} + for day in days: + for key, value in tpl.items(): + # Replace "XXX" with the current day in the key + new_key = key.replace("XXX", day) + scheduler_data[new_key] = value + + last_check = live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] + data = { + "url": test_url, + "fetch_backend": "html_requests" + } + data.update(scheduler_data) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data=data, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + res = client.get(url_for("ui.ui_edit.edit_page", uuid="first")) + assert b"Pacific/Kiritimati" in res.data, "Should be Pacific/Kiritimati in placeholder data" + + # "Edit" should not trigger a check because it's not enabled in the schedule. + time.sleep(2) + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] == last_check + + # Enabling today in Kiritimati should work flawless + kiritimati_time = datetime.now(timezone.utc).astimezone(ZoneInfo("Pacific/Kiritimati")) + kiritimati_time_day_of_week = kiritimati_time.strftime("%A").lower() + live_server.app.config['DATASTORE'].data['watching'][uuid]["time_schedule_limit"][kiritimati_time_day_of_week]["enabled"] = True + time.sleep(3) + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] != last_check + + # Cleanup everything + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_check_basic_global_scheduler_functionality(client, live_server, measure_memory_usage): + + days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + test_url = url_for('test_random_content_endpoint', _external=True) + + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + + # Setup all the days of the weeks using XXX as the placeholder for monday/tuesday/etc + + tpl = { + "requests-time_schedule_limit-XXX-start_time": "00:00", + "requests-time_schedule_limit-XXX-duration-hours": 24, + "requests-time_schedule_limit-XXX-duration-minutes": 0, + "requests-time_schedule_limit-XXX-enabled": '', # All days are turned off + "requests-time_schedule_limit-enabled": 'y', # Scheduler is enabled, all days however are off. + } + + scheduler_data = {} + for day in days: + for key, value in tpl.items(): + # Replace "XXX" with the current day in the key + new_key = key.replace("XXX", day) + scheduler_data[new_key] = value + + data = { + "application-empty_pages_are_a_change": "", + "application-timezone": "Pacific/Kiritimati", # Most Forward Time Zone (UTC+14:00) + 'application-fetch_backend': "html_requests", + "requests-time_between_check-hours": 0, + "requests-time_between_check-minutes": 0, + "requests-time_between_check-seconds": 1, + } + data.update(scheduler_data) + + ##################### + res = client.post( + url_for("settings.settings_page"), + data=data, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + + res = client.get(url_for("settings.settings_page")) + assert b'Pacific/Kiritimati' in res.data + + wait_for_all_checks(client) + + # UI Sanity check + + res = client.get(url_for("ui.ui_edit.edit_page", uuid="first")) + assert b"Pacific/Kiritimati" in res.data, "Should be Pacific/Kiritimati in placeholder data" + + #### HITTING SAVE SHOULD NOT TRIGGER A CHECK + last_check = live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "url": test_url, + "fetch_backend": "html_requests", + "time_between_check_use_default": "y"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + time.sleep(2) + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] == last_check + + # Enabling "today" in Kiritimati time should make the system check that watch + kiritimati_time = datetime.now(timezone.utc).astimezone(ZoneInfo("Pacific/Kiritimati")) + kiritimati_time_day_of_week = kiritimati_time.strftime("%A").lower() + live_server.app.config['DATASTORE'].data['settings']['requests']['time_schedule_limit'][kiritimati_time_day_of_week]["enabled"] = True + + time.sleep(3) + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] != last_check + + # Cleanup everything + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test_search.py b/changedetectionio/tests/test_search.py new file mode 100644 index 0000000000000000000000000000000000000000..754eddc0c54f484a9da6da7edee38cc327225e04 --- /dev/null +++ b/changedetectionio/tests/test_search.py @@ -0,0 +1,73 @@ +from flask import url_for +from .util import set_original_response, set_modified_response, live_server_setup +import time + + + +def test_basic_search(client, live_server, measure_memory_usage): + + + urls = ['https://localhost:12300?first-result=1', + 'https://localhost:7860?second-result=1' + ] + res = client.post( + url_for("imports.import_page"), + data={"urls": "\r\n".join(urls)}, + follow_redirects=True + ) + + assert b"2 Imported" in res.data + + # By URL + res = client.get(url_for("watchlist.index") + "?q=first-res") + assert urls[0].encode('utf-8') in res.data + assert urls[1].encode('utf-8') not in res.data + + # By Title + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"title": "xxx-title", "url": urls[0], "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + res = client.get(url_for("watchlist.index") + "?q=xxx-title") + assert urls[0].encode('utf-8') in res.data + assert urls[1].encode('utf-8') not in res.data + + +def test_search_in_tag_limit(client, live_server, measure_memory_usage): + + + urls = ['https://localhost:12300?first-result=1 tag-one', + 'https://localhost:7860?second-result=1 tag-two' + ] + res = client.post( + url_for("imports.import_page"), + data={"urls": "\r\n".join(urls)}, + follow_redirects=True + ) + + assert b"2 Imported" in res.data + + # By URL + + res = client.get(url_for("watchlist.index") + "?q=first-res") + # Split because of the import tag separation + assert urls[0].split(' ')[0].encode('utf-8') in res.data, urls[0].encode('utf-8') + assert urls[1].split(' ')[0].encode('utf-8') not in res.data, urls[0].encode('utf-8') + + # By Title + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"title": "xxx-title", "url": urls[0].split(' ')[0], "tags": urls[0].split(' ')[1], "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + res = client.get(url_for("watchlist.index") + "?q=xxx-title") + assert urls[0].split(' ')[0].encode('utf-8') in res.data, urls[0].encode('utf-8') + assert urls[1].split(' ')[0].encode('utf-8') not in res.data, urls[0].encode('utf-8') + diff --git a/changedetectionio/tests/test_security.py b/changedetectionio/tests/test_security.py new file mode 100644 index 0000000000000000000000000000000000000000..495e12a83d3565ea577e5ac9e1a2dbfa784ab882 --- /dev/null +++ b/changedetectionio/tests/test_security.py @@ -0,0 +1,120 @@ +import os + +from flask import url_for +from .util import live_server_setup, wait_for_all_checks +from .. import strtobool + + +# def test_setup(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + +def test_bad_access(client, live_server, measure_memory_usage): + + res = client.post( + url_for("imports.import_page"), + data={"urls": 'https://localhost'}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Attempt to add a body with a GET method + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "url": 'javascript:alert(document.domain)', + "tags": "", + "method": "GET", + "fetch_backend": "html_requests", + "body": ""}, + follow_redirects=True + ) + + assert b'Watch protocol is not permitted by SAFE_PROTOCOL_REGEX' in res.data + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": ' javascript:alert(123)', "tags": ''}, + follow_redirects=True + ) + + assert b'Watch protocol is not permitted by SAFE_PROTOCOL_REGEX' in res.data + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": '%20%20%20javascript:alert(123)%20%20', "tags": ''}, + follow_redirects=True + ) + + assert b'Watch protocol is not permitted by SAFE_PROTOCOL_REGEX' in res.data + + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": ' source:javascript:alert(document.domain)', "tags": ''}, + follow_redirects=True + ) + + assert b'Watch protocol is not permitted by SAFE_PROTOCOL_REGEX' in res.data + + +def _runner_test_various_file_slash(client, file_uri): + + client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": file_uri, "tags": ''}, + follow_redirects=True + ) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + + substrings = [b"URLs with hostname components are not permitted", b"No connection adapters were found for"] + + + # If it is enabled at test time + if strtobool(os.getenv('ALLOW_FILE_URI', 'false')): + if file_uri.startswith('file:///'): + # This one should be the full qualified path to the file and should get the contents of this file + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + assert b'_runner_test_various_file_slash' in res.data + else: + # This will give some error from requests or if it went to chrome, will give some other error :-) + assert any(s in res.data for s in substrings) + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_file_slash_access(client, live_server, measure_memory_usage): + + + # file: is NOT permitted by default, so it will be caught by ALLOW_FILE_URI check + + test_file_path = os.path.abspath(__file__) + _runner_test_various_file_slash(client, file_uri=f"file://{test_file_path}") + _runner_test_various_file_slash(client, file_uri=f"file:/{test_file_path}") + _runner_test_various_file_slash(client, file_uri=f"file:{test_file_path}") # CVE-2024-56509 + +def test_xss(client, live_server, measure_memory_usage): + + from changedetectionio.notification import ( + default_notification_format + ) + # the template helpers were named .jinja which meant they were not having jinja2 autoescape enabled. + res = client.post( + url_for("settings.settings_page"), + data={"application-notification_urls": '">', + "application-notification_title": '">', + "application-notification_body": '">', + "application-notification_format": default_notification_format, + "requests-time_between_check-minutes": 180, + 'application-fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"(.*)', html, re.IGNORECASE) + assert share_link_search + + # Now delete what we have, we will try to re-import it + # Cleanup everything + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": share_link_search.group(1)}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + # Now hit edit, we should see what we expect + # that the import fetched the meta-data + + # Check it saved + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + ) + assert bytes(include_filters.encode('utf-8')) in res.data + + # Check it saved the URL + res = client.get(url_for("watchlist.index")) + assert bytes(test_url.encode('utf-8')) in res.data diff --git a/changedetectionio/tests/test_source.py b/changedetectionio/tests/test_source.py new file mode 100644 index 0000000000000000000000000000000000000000..992314c1f24c7749487bee8ca20f958c048b834f --- /dev/null +++ b/changedetectionio/tests/test_source.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from urllib.request import urlopen +from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks + +sleep_time_for_fetch_thread = 3 + + + +def test_check_basic_change_detection_functionality_source(client, live_server, measure_memory_usage): + set_original_response() + test_url = 'source:'+url_for('test_endpoint', _external=True) + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + time.sleep(sleep_time_for_fetch_thread) + + ##################### + + # Check HTML conversion detected and workd + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + # Check this class DOES appear (that we didnt see the actual source) + assert b'foobar-detection' in res.data + + # Make a change + set_modified_response() + + # Force recheck + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + wait_for_all_checks(client) + + # Now something should be ready, indicated by having a 'unviewed' class + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + res = client.get( + url_for("ui.ui_views.diff_history_page", uuid="first"), + follow_redirects=True + ) + + assert b'<title>modified head title' in res.data + + + +# `subtractive_selectors` should still work in `source:` type requests +def test_check_ignore_elements(client, live_server, measure_memory_usage): + set_original_response() + time.sleep(1) + test_url = 'source:'+url_for('test_endpoint', _external=True) + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + wait_for_all_checks(client) + + ##################### + # We want and

    ONLY, but ignore span with .foobar-detection + + client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": 'span,p', "url": test_url, "tags": "", "subtractive_selectors": ".foobar-detection", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + + time.sleep(sleep_time_for_fetch_thread) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + assert b'foobar-detection' not in res.data + assert b'<br' not in res.data + assert b'<p' in res.data diff --git a/changedetectionio/tests/test_trigger.py b/changedetectionio/tests/test_trigger.py new file mode 100644 index 0000000000000000000000000000000000000000..0df5ec3b55d520644e85b0bee5d0bf69ff5a04b1 --- /dev/null +++ b/changedetectionio/tests/test_trigger.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + + +def set_original_ignore_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def set_modified_original_ignore_response(): + test_return_data = """ + + Some NEW nice initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def set_modified_with_trigger_text_response(): + test_return_data = """ + + Some NEW nice initial text
    +

    Which is across multiple lines

    +
    + Add to cart +
    + So let's see what happens.
    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def test_trigger_functionality(client, live_server, measure_memory_usage): + + # live_server_setup(live_server) # Setup on conftest per function + + trigger_text = "Add to cart" + set_original_ignore_response() + + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"trigger_text": trigger_text, + "url": test_url, + "fetch_backend": "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + wait_for_all_checks(client) + # Check it saved + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + ) + assert bytes(trigger_text.encode('utf-8')) in res.data + + + + # so that we set the state to 'unviewed' after all the edits + client.get(url_for("ui.ui_views.diff_history_page", uuid="first")) + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + assert b'/test-endpoint' in res.data + + # Make a change + set_modified_original_ignore_response() + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + # Now set the content which contains the trigger text + set_modified_with_trigger_text_response() + + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + # https://github.com/dgtlmoon/changedetection.io/issues/616 + # Apparently the actual snapshot that contains the trigger never shows + res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first")) + assert b'Add to cart' in res.data + + # Check the preview/highlighter, we should be able to see what we triggered on, but it should be highlighted + res = client.get(url_for("ui.ui_views.preview_page", uuid="first")) + + # We should be able to see what we triggered on + # The JS highlighter should tell us which lines (also used in the live-preview) + assert b'const triggered_line_numbers = [6]' in res.data + assert b'Add to cart' in res.data + diff --git a/changedetectionio/tests/test_trigger_regex.py b/changedetectionio/tests/test_trigger_regex.py new file mode 100644 index 0000000000000000000000000000000000000000..25253f216f63556a06d31310f530f4d4f45e2511 --- /dev/null +++ b/changedetectionio/tests/test_trigger_regex.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + + +def set_original_ignore_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + + +def test_trigger_regex_functionality(client, live_server, measure_memory_usage): + + # live_server_setup(live_server) # Setup on conftest per function + + set_original_ignore_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should report nothing found (just a new one shouldnt have anything) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + ### test regex + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"trigger_text": '/something \d{3}/', + "url": test_url, + "fetch_backend": "html_requests"}, + follow_redirects=True + ) + wait_for_all_checks(client) + # so that we set the state to 'unviewed' after all the edits + client.get(url_for("ui.ui_views.diff_history_page", uuid="first")) + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write("some new noise") + + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + # It should report nothing found (nothing should match the regex) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write("regex test123
    \nsomething 123") + + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + + # Cleanup everything + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test_trigger_regex_with_filter.py b/changedetectionio/tests/test_trigger_regex_with_filter.py new file mode 100644 index 0000000000000000000000000000000000000000..a78b8fc4647aa0a19eb28fb8f376f2e2d5d65937 --- /dev/null +++ b/changedetectionio/tests/test_trigger_regex_with_filter.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from . util import live_server_setup + + +def set_original_ignore_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + + +def test_trigger_regex_functionality_with_filter(client, live_server, measure_memory_usage): + + # live_server_setup(live_server) # Setup on conftest per function + sleep_time_for_fetch_thread = 3 + + set_original_ignore_response() + + # Give the endpoint time to spin up + time.sleep(1) + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # it needs time to save the original version + time.sleep(sleep_time_for_fetch_thread) + + ### test regex with filter + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"trigger_text": "/cool.stuff/", + "url": test_url, + "include_filters": '#in-here', + "fetch_backend": "html_requests"}, + follow_redirects=True + ) + + # Give the thread time to pick it up + time.sleep(sleep_time_for_fetch_thread) + + client.get(url_for("ui.ui_views.diff_history_page", uuid="first")) + + # Check that we have the expected text.. but it's not in the css filter we want + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write("some new noise with cool stuff2 ok") + + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + time.sleep(sleep_time_for_fetch_thread) + + # It should report nothing found (nothing should match the regex and filter) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + # now this should trigger something + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write("some new noise with cool stuff6 ok") + + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + time.sleep(sleep_time_for_fetch_thread) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + +# Cleanup everything + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test_ui.py b/changedetectionio/tests/test_ui.py new file mode 100644 index 0000000000000000000000000000000000000000..aec1ff601e7c981df53ba951a4a12def1fae3498 --- /dev/null +++ b/changedetectionio/tests/test_ui.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 + +from flask import url_for +from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks + +def test_checkbox_open_diff_in_new_tab(client, live_server): + + set_original_response() + # live_server_setup(live_server) # Setup on conftest per function + + # Add our URL to the import page + res = client.post( + url_for("imports.import_page"), + data={"urls": url_for('test_endpoint', _external=True)}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Make a change + set_modified_response() + + # Test case 1 - checkbox is enabled in settings + res = client.post( + url_for("settings.settings_page"), + data={"application-ui-open_diff_in_new_tab": "1"}, + follow_redirects=True + ) + assert b'Settings updated' in res.data + + # Force recheck + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + lines = res.data.decode().split("\n") + + # Find link to diff page + target_line = None + for line in lines: + if '/diff' in line: + target_line = line.strip() + break + + assert target_line != None + assert 'target=' in target_line + + # Test case 2 - checkbox is disabled in settings + res = client.post( + url_for("settings.settings_page"), + data={"application-ui-open_diff_in_new_tab": ""}, + follow_redirects=True + ) + assert b'Settings updated' in res.data + + # Force recheck + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + lines = res.data.decode().split("\n") + + # Find link to diff page + target_line = None + for line in lines: + if '/diff' in line: + target_line = line.strip() + break + + assert target_line != None + assert 'target=' not in target_line + + # Cleanup everything + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data diff --git a/changedetectionio/tests/test_unique_lines.py b/changedetectionio/tests/test_unique_lines.py new file mode 100644 index 0000000000000000000000000000000000000000..b4829e62e493bdcd6d4ab4c03bbc216e11df8bee --- /dev/null +++ b/changedetectionio/tests/test_unique_lines.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + + +def set_original_ignore_response(): + test_return_data = """ + +

    Some initial text

    +

    Which is across multiple lines

    +

    So let's see what happens.

    +

      So let's see what happens.

    +

    A - sortable line

    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +# The same but just re-ordered the text +def set_modified_swapped_lines(): + # Re-ordered and with some whitespacing, should get stripped() too. + test_return_data = """ + +

    Some initial text

    +

    So let's see what happens.

    +

     Which is across multiple lines

    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + +def set_modified_swapped_lines_with_extra_text_for_sorting(): + test_return_data = """ + +

     Which is across multiple lines

    +

    Some initial text

    +

    So let's see what happens.

    +

    Z last

    +

    0 numerical

    +

    A uppercase

    +

    a lowercase

    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + +def set_modified_with_trigger_text_response(): + test_return_data = """ + +

    Some initial text

    +

    So let's see what happens.

    +

    and a new line!

    +

    Which is across multiple lines

    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + +# def test_setup(client, live_server, measure_memory_usage): + # live_server_setup(live_server) # Setup on conftest per function + +def test_unique_lines_functionality(client, live_server, measure_memory_usage): + + + + set_original_ignore_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"check_unique_lines": "y", + "url": test_url, + "fetch_backend": "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + assert b'unviewed' not in res.data + + # Make a change + set_modified_swapped_lines() + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + + # Now set the content which contains the new text and re-ordered existing text + set_modified_with_trigger_text_response() + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'unviewed' in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_sort_lines_functionality(client, live_server, measure_memory_usage): + + + set_modified_swapped_lines_with_extra_text_for_sorting() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"sort_text_alphabetically": "n", + "url": test_url, + "fetch_backend": "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # Give the thread time to pick it up + wait_for_all_checks(client) + + + res = client.get(url_for("watchlist.index")) + # Should be a change registered + assert b'unviewed' in res.data + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert res.data.find(b'0 numerical') < res.data.find(b'Z last') + assert res.data.find(b'A uppercase') < res.data.find(b'Z last') + assert res.data.find(b'Some initial text') < res.data.find(b'Which is across multiple lines') + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_extra_filters(client, live_server, measure_memory_usage): + + + set_original_ignore_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"remove_duplicate_lines": "y", + "trim_text_whitespace": "y", + "sort_text_alphabetically": "", # leave this OFF for testing + "url": test_url, + "fetch_backend": "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + # Give the thread time to pick it up + wait_for_all_checks(client) + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + # Give the thread time to pick it up + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first") + ) + + assert res.data.count(b"see what happens.") == 1 + + # still should remain unsorted ('A - sortable line') stays at the end + assert res.data.find(b'A - sortable line') > res.data.find(b'Which is across multiple lines') + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data \ No newline at end of file diff --git a/changedetectionio/tests/test_watch_fields_storage.py b/changedetectionio/tests/test_watch_fields_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..506722fe2a87e2066ac02dbfc74e0fab382988f2 --- /dev/null +++ b/changedetectionio/tests/test_watch_fields_storage.py @@ -0,0 +1,56 @@ +import time +from flask import url_for +from urllib.request import urlopen +from . util import set_original_response, set_modified_response, live_server_setup + + +def test_check_watch_field_storage(client, live_server, measure_memory_usage): + set_original_response() + # live_server_setup(live_server) # Setup on conftest per function + + test_url = "http://somerandomsitewewatch.com" + + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ "notification_urls": "json://127.0.0.1:30000\r\njson://128.0.0.1\r\n", + "time_between_check-minutes": 126, + "include_filters" : ".fooclass", + "title" : "My title", + "ignore_text" : "ignore this", + "url": test_url, + "tags": "woohoo", + "headers": "curl:foo", + 'fetch_backend': "html_requests" + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + follow_redirects=True + ) + # checks that we dont get an error when using blank lines in the field value + assert not b"json://127.0.0.1\n\njson" in res.data + assert not b"json://127.0.0.1\r\n\njson" in res.data + assert not b"json://127.0.0.1\r\n\rjson" in res.data + + assert b"json://127.0.0.1" in res.data + assert b"json://128.0.0.1" in res.data + + assert b"126" in res.data + assert b".fooclass" in res.data + assert b"My title" in res.data + assert b"ignore this" in res.data + assert b"http://somerandomsitewewatch.com" in res.data + assert b"woohoo" in res.data + assert b"curl: foo" in res.data + diff --git a/changedetectionio/tests/test_xpath_selector.py b/changedetectionio/tests/test_xpath_selector.py new file mode 100644 index 0000000000000000000000000000000000000000..fbdf201c6dd75bd4043b2434d3c78cf720e90108 --- /dev/null +++ b/changedetectionio/tests/test_xpath_selector.py @@ -0,0 +1,572 @@ +# -*- coding: utf-8 -*- + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks + +from ..html_tools import * + + + + + +def set_original_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text thats the same
    +
    Some text that will change
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + + +def set_modified_response(): + test_return_data = """ + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens. THIS CHANGES AND SHOULDNT TRIGGER A CHANGE
    +
    Some text thats the same
    +
    Some new text
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + return None + + +# Handle utf-8 charset replies https://github.com/dgtlmoon/changedetection.io/pull/613 +def test_check_xpath_filter_utf8(client, live_server, measure_memory_usage): + filter = '//item/*[self::description]' + + d = ''' + + + rpilocator.com + https://rpilocator.com + Find Raspberry Pi Computers in Stock + Thu, 19 May 2022 23:27:30 GMT + + https://rpilocator.com/favicon.png + rpilocator.com + https://rpilocator.com/ + 32 + 32 + + + Stock Alert (UK): RPi CM4 - 1GB RAM, No MMC, No Wifi is In Stock at Pimoroni + Stock Alert (UK): RPi CM4 - 1GB RAM, No MMC, No Wifi is In Stock at Pimoroni + https://rpilocator.com?vendor=pimoroni&utm_source=feed&utm_medium=rss + pimoroni + UK + CM4 + F9FAB0D9-DF6F-40C8-8DEE5FC0646BB722 + Thu, 19 May 2022 14:32:32 GMT + + +''' + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(d) + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True, content_type="application/rss+xml;charset=UTF-8") + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'Unicode strings with encoding declaration are not supported.' not in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +# Handle utf-8 charset replies https://github.com/dgtlmoon/changedetection.io/pull/613 +def test_check_xpath_text_function_utf8(client, live_server, measure_memory_usage): + filter = '//item/title/text()' + + d = ''' + + + rpilocator.com + https://rpilocator.com + Find Raspberry Pi Computers in Stock + Thu, 19 May 2022 23:27:30 GMT + + https://rpilocator.com/favicon.png + rpilocator.com + https://rpilocator.com/ + 32 + 32 + + + Stock Alert (UK): RPi CM4 + something else unrelated + + + Stock Alert (UK): Big monitor + something else unrelated + + +''' + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(d) + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True, content_type="application/rss+xml;charset=UTF-8") + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'Unicode strings with encoding declaration are not supported.' not in res.data + + # The service should echo back the request headers + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b'Stock Alert (UK): RPi CM4' in res.data + assert b'Stock Alert (UK): Big monitor' in res.data + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_check_markup_xpath_filter_restriction(client, live_server, measure_memory_usage): + xpath_filter = "//*[contains(@class, 'sametext')]" + + set_original_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": xpath_filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + # Give the thread time to pick it up + wait_for_all_checks(client) + + # view it/reset state back to viewed + client.get(url_for("ui.ui_views.diff_history_page", uuid="first"), follow_redirects=True) + + # Make a change + set_modified_response() + + # Trigger a check + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + # Give the thread time to pick it up + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + assert b'unviewed' not in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_xpath_validation(client, live_server, measure_memory_usage): + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": "/something horrible", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"is not a valid XPath expression" in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_xpath23_prefix_validation(client, live_server, measure_memory_usage): + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": "xpath:/something horrible", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"is not a valid XPath expression" in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + +def test_xpath1_lxml(client, live_server, measure_memory_usage): + + + d = ''' + + + rpilocator.com + https://rpilocator.com + Find Raspberry Pi Computers in Stock + Thu, 19 May 2022 23:27:30 GMT + + https://rpilocator.com/favicon.png + rpilocator.com + https://rpilocator.com/ + 32 + 32 + + + Stock Alert (UK): RPi CM4 + something else unrelated + + + Stock Alert (UK): Big monitorěěěě + something else unrelated + + + '''.encode('utf-8') + + with open("test-datastore/endpoint-content.txt", "wb") as f: + f.write(d) + + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": "xpath1://title/text()", "url": test_url, "tags": "", "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + + ##### #2312 + wait_for_all_checks(client) + res = client.get(url_for("watchlist.index")) + assert b'_ElementStringResult' not in res.data # tested with 5.1.1 when it was removed and 5.1.0 + assert b'Exception' not in res.data + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b"rpilocator.com" in res.data # in selector + assert "Stock Alert (UK): Big monitorěěěě".encode('utf-8') in res.data # not in selector + + ##### + + +def test_xpath1_validation(client, live_server, measure_memory_usage): + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": "xpath1:/something horrible", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"is not a valid XPath expression" in res.data + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +# actually only really used by the distll.io importer, but could be handy too +def test_check_with_prefix_include_filters(client, live_server, measure_memory_usage): + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + set_original_response() + wait_for_all_checks(client) + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": "xpath://*[contains(@class, 'sametext')]", "url": test_url, "tags": "", "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b"Some text thats the same" in res.data # in selector + assert b"Some text that will change" not in res.data # not in selector + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + +def test_various_rules(client, live_server, measure_memory_usage): + # Just check these don't error + ## live_server_setup(live_server) # Setup on conftest per function + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(""" + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    +
    Some text thats the same
    +
    Some text that will change
    + some linky + another some linky + + + + + """) + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + for r in ['//div', '//a', 'xpath://div', 'xpath://a']: + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": r, + "url": test_url, + "tags": "", + "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + wait_for_all_checks(client) + assert b"Updated watch." in res.data + res = client.get(url_for("watchlist.index")) + assert b'fetch-error' not in res.data, f"Should not see errors after '{r} filter" + + res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_xpath_20(client, live_server, measure_memory_usage): + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + set_original_response() + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": "//*[contains(@class, 'sametext')]|//*[contains(@class, 'changetext')]", + "url": test_url, + "tags": "", + "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b"Some text thats the same" in res.data # in selector + assert b"Some text that will change" in res.data # in selector + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + +def test_xpath_20_function_count(client, live_server, measure_memory_usage): + set_original_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": "xpath:count(//div) * 123456789987654321", + "url": test_url, + "tags": "", + "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b"246913579975308642" in res.data # in selector + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + +def test_xpath_20_function_count2(client, live_server, measure_memory_usage): + set_original_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={"include_filters": "/html/body/count(div) * 123456789987654321", + "url": test_url, + "tags": "", + "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b"246913579975308642" in res.data # in selector + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + +def test_xpath_20_function_string_join_matches(client, live_server, measure_memory_usage): + set_original_response() + + # Add our URL to the import page + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("imports.import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + wait_for_all_checks(client) + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "include_filters": "xpath:string-join(//*[contains(@class, 'sametext')]|//*[matches(@class, 'changetext')], 'specialconjunction')", + "url": test_url, + "tags": "", + "headers": "", + 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + + assert b"Updated watch." in res.data + wait_for_all_checks(client) + + res = client.get( + url_for("ui.ui_views.preview_page", uuid="first"), + follow_redirects=True + ) + + assert b"Some text thats the samespecialconjunctionSome text that will change" in res.data # in selector + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + diff --git a/changedetectionio/tests/test_xpath_selector_unit.py b/changedetectionio/tests/test_xpath_selector_unit.py new file mode 100644 index 0000000000000000000000000000000000000000..b4dda08068a8a3e6e3c33bf89263a4e81c8e592a --- /dev/null +++ b/changedetectionio/tests/test_xpath_selector_unit.py @@ -0,0 +1,203 @@ +import sys +import os +import pytest +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import html_tools + +# test generation guide. +# 1. Do not include encoding in the xml declaration if the test object is a str type. +# 2. Always paraphrase test. + +hotels = """ + + + + Christopher + Anderson + 25 + + + Christopher + Carter + 30 + + + + + Lisa + Walker + 60 + + + Jessica + Walker + 32 + + + Jennifer + Roberts + 50 + + +""" + +@pytest.mark.parametrize("html_content", [hotels]) +@pytest.mark.parametrize("xpath, answer", [('(//staff/given_name, //staff/age)', '25'), + ("xs:date('2023-10-10')", '2023-10-10'), + ("if (/hotel/branch[@location = 'California']/staff[1]/age = 25) then 'is 25' else 'is not 25'", 'is 25'), + ("if (//hotel/branch[@location = 'California']/staff[1]/age = 25) then 'is 25' else 'is not 25'", 'is 25'), + ("if (count(/hotel/branch/staff) = 5) then true() else false()", 'true'), + ("if (count(//hotel/branch/staff) = 5) then true() else false()", 'true'), + ("for $i in /hotel/branch/staff return if ($i/age >= 40) then upper-case($i/surname) else lower-case($i/surname)", 'anderson'), + ("given_name = 'Christopher' and age = 40", 'false'), + ("//given_name = 'Christopher' and //age = 40", 'false'), + #("(staff/given_name, staff/age)", 'Lisa'), + ("(//staff/given_name, //staff/age)", 'Lisa'), + #("hotel/branch[@location = 'California']/staff/age union hotel/branch[@location = 'Las Vegas']/staff/age", ''), + ("(//hotel/branch[@location = 'California']/staff/age union //hotel/branch[@location = 'Las Vegas']/staff/age)", '60'), + ("(200 to 210)", "205"), + ("(//hotel/branch[@location = 'California']/staff/age union //hotel/branch[@location = 'Las Vegas']/staff/age)", "50"), + ("(1, 9, 9, 5)", "5"), + ("(3, (), (14, 15), 92, 653)", "653"), + ("for $i in /hotel/branch/staff return $i/given_name", "Christopher"), + ("for $i in //hotel/branch/staff return $i/given_name", "Christopher"), + ("distinct-values(for $i in /hotel/branch/staff return $i/given_name)", "Jessica"), + ("distinct-values(for $i in //hotel/branch/staff return $i/given_name)", "Jessica"), + ("for $i in (7 to 15) return $i*10", "130"), + ("some $i in /hotel/branch/staff satisfies $i/age < 20", "false"), + ("some $i in //hotel/branch/staff satisfies $i/age < 20", "false"), + ("every $i in /hotel/branch/staff satisfies $i/age > 20", "true"), + ("every $i in //hotel/branch/staff satisfies $i/age > 20 ", "true"), + ("let $x := branch[@location = 'California'], $y := branch[@location = 'Las Vegas'] return (avg($x/staff/age), avg($y/staff/age))", "27.5"), + ("let $x := //branch[@location = 'California'], $y := //branch[@location = 'Las Vegas'] return (avg($x/staff/age), avg($y/staff/age))", "27.5"), + ("let $nu := 1, $de := 1000 return 'probability = ' || $nu div $de * 100 || '%'", "0.1%"), + ("let $nu := 2, $probability := function ($argument) { 'probability = ' || $nu div $argument * 100 || '%'}, $de := 5 return $probability($de)", "40%"), + ("'XPATH2.0-3.1 dissemination' instance of xs:string ", "true"), + ("'new stackoverflow question incoming' instance of xs:integer ", "false"), + ("'50000' cast as xs:integer", "50000"), + ("//branch[@location = 'California']/staff[1]/surname eq 'Anderson'", "true"), + ("fn:false()", "false")]) +def test_hotels(html_content, xpath, answer): + html_content = html_tools.xpath_filter(xpath, html_content, append_pretty_line_formatting=True) + assert type(html_content) == str + assert answer in html_content + + + +branches_to_visit = """ + + + Area 51 + A place with no name + Stalsk12 + + + Stalsk12 + Barcelona + Paris + + """ +@pytest.mark.parametrize("html_content", [branches_to_visit]) +@pytest.mark.parametrize("xpath, answer", [ + ("manager[@name = 'Godot']/branch union manager[@name = 'Freya']/branch", "Area 51"), + ("//manager[@name = 'Godot']/branch union //manager[@name = 'Freya']/branch", "Stalsk12"), + ("manager[@name = 'Godot']/branch | manager[@name = 'Freya']/branch", "Stalsk12"), + ("//manager[@name = 'Godot']/branch | //manager[@name = 'Freya']/branch", "Stalsk12"), + ("manager/branch intersect manager[@name = 'Godot']/branch", "A place with no name"), + ("//manager/branch intersect //manager[@name = 'Godot']/branch", "A place with no name"), + ("manager[@name = 'Godot']/branch intersect manager[@name = 'Freya']/branch", ""), + ("manager/branch except manager[@name = 'Godot']/branch", "Barcelona"), + ("manager[@name = 'Godot']/branch[1] eq 'Area 51'", "true"), + ("//manager[@name = 'Godot']/branch[1] eq 'Area 51'", "true"), + ("manager[@name = 'Godot']/branch[1] eq 'Seoul'", "false"), + ("//manager[@name = 'Godot']/branch[1] eq 'Seoul'", "false"), + ("manager[@name = 'Godot']/branch[2] eq manager[@name = 'Freya']/branch[2]", "false"), + ("//manager[@name = 'Godot']/branch[2] eq //manager[@name = 'Freya']/branch[2]", "false"), + ("manager[1]/@room_no lt manager[2]/@room_no", "false"), + ("//manager[1]/@room_no lt //manager[2]/@room_no", "false"), + ("manager[1]/@room_no gt manager[2]/@room_no", "true"), + ("//manager[1]/@room_no gt //manager[2]/@room_no", "true"), + ("manager[@name = 'Godot']/branch[1] = 'Area 51'", "true"), + ("//manager[@name = 'Godot']/branch[1] = 'Area 51'", "true"), + ("manager[@name = 'Godot']/branch[1] = 'Seoul'", "false"), + ("//manager[@name = 'Godot']/branch[1] = 'Seoul'", "false"), + ("manager[@name = 'Godot']/branch = 'Area 51'", "true"), + ("//manager[@name = 'Godot']/branch = 'Area 51'", "true"), + ("manager[@name = 'Godot']/branch = 'Barcelona'", "false"), + ("//manager[@name = 'Godot']/branch = 'Barcelona'", "false"), + ("manager[1]/@room_no > manager[2]/@room_no", "true"), + ("//manager[1]/@room_no > //manager[2]/@room_no", "true"), + ("manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is manager[1]/branch[1]", "false"), + ("//manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is //manager[1]/branch[1]", "false"), + ("manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is manager[1]/branch[3]", "true"), + ("//manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is //manager[1]/branch[3]", "true"), + ("manager[@name = 'Godot']/branch[ . = 'Stalsk12'] << manager[1]/branch[1]", "false"), + ("//manager[@name = 'Godot']/branch[ . = 'Stalsk12'] << //manager[1]/branch[1]", "false"), + ("manager[@name = 'Godot']/branch[ . = 'Stalsk12'] >> manager[1]/branch[1]", "true"), + ("//manager[@name = 'Godot']/branch[ . = 'Stalsk12'] >> //manager[1]/branch[1]", "true"), + ("manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is manager[@name = 'Freya']/branch[ . = 'Stalsk12']", "false"), + ("//manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is //manager[@name = 'Freya']/branch[ . = 'Stalsk12']", "false"), + ("manager[1]/@name || manager[2]/@name", "GodotFreya"), + ("//manager[1]/@name || //manager[2]/@name", "GodotFreya"), + ]) +def test_branches_to_visit(html_content, xpath, answer): + html_content = html_tools.xpath_filter(xpath, html_content, append_pretty_line_formatting=True) + assert type(html_content) == str + assert answer in html_content + +trips = """ + + + 2023-10-06 + 2023-10-10 + + 4 + 2000.00 + + + + 2023-10-06 + 2023-10-12 + + 6 + 3500.34 + + +""" +@pytest.mark.parametrize("html_content", [trips]) +@pytest.mark.parametrize("xpath, answer", [ + ("1 + 9 * 9 + 5 div 5", "83"), + ("(1 + 9 * 9 + 5) div 6", "14.5"), + ("23 idiv 3", "7"), + ("23 div 3", "7.66666666"), + ("for $i in ./trip return $i/traveler/duration * $i/traveler/price", "21002.04"), + ("for $i in ./trip return $i/traveler/duration ", "4"), + ("for $i in .//trip return $i/traveler/duration * $i/traveler/price", "21002.04"), + ("sum(for $i in ./trip return $i/traveler/duration * $i/traveler/price)", "29002.04"), + ("sum(for $i in .//trip return $i/traveler/duration * $i/traveler/price)", "29002.04"), + #("trip[1]/depart - trip[1]/arrive", "fail_to_get_answer"), + #("//trip[1]/depart - //trip[1]/arrive", "fail_to_get_answer"), + #("trip[1]/depart + trip[1]/arrive", "fail_to_get_answer"), + #("xs:date(trip[1]/depart) + xs:date(trip[1]/arrive)", "fail_to_get_answer"), + ("(//trip[1]/arrive cast as xs:date) - (//trip[1]/depart cast as xs:date)", "P4D"), + ("(//trip[1]/depart cast as xs:date) - (//trip[1]/arrive cast as xs:date)", "-P4D"), + ("(//trip[1]/depart cast as xs:date) + xs:dayTimeDuration('P3D')", "2023-10-09"), + ("(//trip[1]/depart cast as xs:date) - xs:dayTimeDuration('P3D')", "2023-10-03"), + ("(456, 623) instance of xs:integer", "false"), + ("(456, 623) instance of xs:integer*", "true"), + ("/trips/trip instance of element()", "false"), + ("/trips/trip instance of element()*", "true"), + ("/trips/trip[1]/arrive instance of xs:date", "false"), + ("date(/trips/trip[1]/arrive) instance of xs:date", "true"), + ("'8' cast as xs:integer", "8"), + ("'11.1E3' cast as xs:double", "11100"), + ("6.5 cast as xs:integer", "6"), + #("/trips/trip[1]/arrive cast as xs:dateTime", "fail_to_get_answer"), + ("/trips/trip[1]/arrive cast as xs:date", "2023-10-10"), + ("('2023-10-12') cast as xs:date", "2023-10-12"), + ("for $i in //trip return concat($i/depart, ' ', $i/arrive)", "2023-10-06 2023-10-10"), + ]) +def test_trips(html_content, xpath, answer): + html_content = html_tools.xpath_filter(xpath, html_content, append_pretty_line_formatting=True) + assert type(html_content) == str + assert answer in html_content diff --git a/changedetectionio/tests/unit/__init__.py b/changedetectionio/tests/unit/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..368609ff15d474e505d5b2089f136830c0575b4e --- /dev/null +++ b/changedetectionio/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the app.""" diff --git a/changedetectionio/tests/unit/test-content/README.md b/changedetectionio/tests/unit/test-content/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a1e55fbc8ecd02b5ade21d5b1f75f602afff2539 --- /dev/null +++ b/changedetectionio/tests/unit/test-content/README.md @@ -0,0 +1,5 @@ +# What is this? +This is test content for the python diff engine, we use the JS interface for the front end, because you can explore +differences in words etc, but we use (at the moment) the python difflib engine. + +This content `before.txt` and `after.txt` is for unit testing diff --git a/changedetectionio/tests/unit/test-content/after-2.txt b/changedetectionio/tests/unit/test-content/after-2.txt new file mode 100644 index 0000000000000000000000000000000000000000..49030e7d04eca42c9dd8359b83340adaf669b7d8 --- /dev/null +++ b/changedetectionio/tests/unit/test-content/after-2.txt @@ -0,0 +1,3 @@ +After twenty years, as cursed as I may be +ok +and insure that I'm one of those computer nerds. diff --git a/changedetectionio/tests/unit/test-content/after.txt b/changedetectionio/tests/unit/test-content/after.txt new file mode 100644 index 0000000000000000000000000000000000000000..7327be2201c606a06ffe8e9c335100c34b8b3395 --- /dev/null +++ b/changedetectionio/tests/unit/test-content/after.txt @@ -0,0 +1,7 @@ +After twenty years, as cursed as I may be +for having learned computerese, +I continue to examine bits, bytes and words +xok +next-x-ok +and insure that I'm one of those computer nerds. +and something new \ No newline at end of file diff --git a/changedetectionio/tests/unit/test-content/before.txt b/changedetectionio/tests/unit/test-content/before.txt new file mode 100644 index 0000000000000000000000000000000000000000..8ca997dfdda7a033515acf892f67d50f5b457994 --- /dev/null +++ b/changedetectionio/tests/unit/test-content/before.txt @@ -0,0 +1,5 @@ +After twenty years, as cursed as I may be +for having learned computerese, +I continue to examine bits, bytes and words +ok +and insure that I'm one of those computer nerds. diff --git a/changedetectionio/tests/unit/test_conditions.py b/changedetectionio/tests/unit/test_conditions.py new file mode 100644 index 0000000000000000000000000000000000000000..0de147cf2955b5e610ce2e2669e6e568d1cabc23 --- /dev/null +++ b/changedetectionio/tests/unit/test_conditions.py @@ -0,0 +1,82 @@ +from changedetectionio.conditions import execute_ruleset_against_all_plugins +from changedetectionio.store import ChangeDetectionStore +import shutil +import tempfile +import time +import unittest +import uuid + + +class TestTriggerConditions(unittest.TestCase): + def setUp(self): + + # Create a temporary directory for the test datastore + self.test_datastore_path = tempfile.mkdtemp() + + # Initialize ChangeDetectionStore with our test path and no default watches + self.store = ChangeDetectionStore( + datastore_path=self.test_datastore_path, + include_default_watches=False + ) + + # Add a test watch + watch_url = "https://example.com" + self.watch_uuid = self.store.add_watch(url=watch_url) + + def tearDown(self): + # Clean up the test datastore + self.store.stop_thread = True + time.sleep(0.5) # Give thread time to stop + shutil.rmtree(self.test_datastore_path) + + def test_conditions_execution_pass(self): + # Get the watch object + watch = self.store.data['watching'][self.watch_uuid] + + # Create and save a snapshot + first_content = "I saw 100 people at a rock show" + timestamp1 = int(time.time()) + snapshot_id1 = str(uuid.uuid4()) + watch.save_history_text(contents=first_content, + timestamp=timestamp1, + snapshot_id=snapshot_id1) + + # Add another snapshot + second_content = "I saw 200 people at a rock show" + timestamp2 = int(time.time()) + 60 + snapshot_id2 = str(uuid.uuid4()) + watch.save_history_text(contents=second_content, + timestamp=timestamp2, + snapshot_id=snapshot_id2) + + # Verify both snapshots are stored + history = watch.history + self.assertEqual(len(history), 2) + + # Retrieve and check snapshots + #snapshot1 = watch.get_history_snapshot(str(timestamp1)) + #snapshot2 = watch.get_history_snapshot(str(timestamp2)) + + self.store.data['watching'][self.watch_uuid].update( + { + "conditions_match_logic": "ALL", + "conditions": [ + {"operator": ">=", "field": "extracted_number", "value": "10"}, + {"operator": "<=", "field": "extracted_number", "value": "5000"}, + {"operator": "in", "field": "page_text", "value": "rock"}, + #{"operator": "starts_with", "field": "page_text", "value": "I saw"}, + ] + } + ) + + # ephemeral_data - some data that could exist before the watch saved a new version + result = execute_ruleset_against_all_plugins(current_watch_uuid=self.watch_uuid, + application_datastruct=self.store.data, + ephemeral_data={'text': "I saw 500 people at a rock show"}) + + # @todo - now we can test that 'Extract number' increased more than X since last time + self.assertTrue(result.get('result')) + + +if __name__ == '__main__': + unittest.main() diff --git a/changedetectionio/tests/unit/test_jinja2_security.py b/changedetectionio/tests/unit/test_jinja2_security.py new file mode 100644 index 0000000000000000000000000000000000000000..7616bb657510dc6168af5e8a0a38ff07e674a784 --- /dev/null +++ b/changedetectionio/tests/unit/test_jinja2_security.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 + +# run from dir above changedetectionio/ dir +# python3 -m unittest changedetectionio.tests.unit.test_jinja2_security + +import unittest +from changedetectionio import safe_jinja + + +# mostly +class TestJinja2SSTI(unittest.TestCase): + + def test_exception(self): + import jinja2 + + # Where sandbox should kick in + attempt_list = [ + "My name is {{ self.__init__.__globals__.__builtins__.__import__('os').system('id') }}", + "{{ self._TemplateReference__context.cycler.__init__.__globals__.os }}", + "{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}", + "{{cycler.__init__.__globals__.os.popen('id').read()}}", + "{{joiner.__init__.__globals__.os.popen('id').read()}}", + "{{namespace.__init__.__globals__.os.popen('id').read()}}", + "{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/hello.txt', 'w').write('Hello here !') }}", + "My name is {{ self.__init__.__globals__ }}", + "{{ dict.__base__.__subclasses__() }}" + ] + for attempt in attempt_list: + with self.assertRaises(jinja2.exceptions.SecurityError): + safe_jinja.render(attempt) + + def test_exception_debug_calls(self): + import jinja2 + # Where sandbox should kick in - configs and debug calls + attempt_list = [ + "{% debug %}", + ] + for attempt in attempt_list: + # Usually should be something like 'Encountered unknown tag 'debug'.' + with self.assertRaises(jinja2.exceptions.TemplateSyntaxError): + safe_jinja.render(attempt) + + # https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection/jinja2-ssti#accessing-global-objects + def test_exception_empty_calls(self): + import jinja2 + attempt_list = [ + "{{config}}", + "{{ debug }}" + "{{[].__class__}}", + ] + for attempt in attempt_list: + self.assertEqual(len(safe_jinja.render(attempt)), 0, f"string test '{attempt}' is correctly empty") + + + +if __name__ == '__main__': + unittest.main() diff --git a/changedetectionio/tests/unit/test_notification_diff.py b/changedetectionio/tests/unit/test_notification_diff.py new file mode 100644 index 0000000000000000000000000000000000000000..b106f6a14ea6df60e716c798d16d8d96e9ece7d4 --- /dev/null +++ b/changedetectionio/tests/unit/test_notification_diff.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 + +# run from dir above changedetectionio/ dir +# python3 -m unittest changedetectionio.tests.unit.test_notification_diff + +import unittest +import os + +from changedetectionio import diff + +# mostly +class TestDiffBuilder(unittest.TestCase): + + def test_expected_diff_output(self): + base_dir = os.path.dirname(__file__) + with open(base_dir + "/test-content/before.txt", 'r') as f: + previous_version_file_contents = f.read() + + with open(base_dir + "/test-content/after.txt", 'r') as f: + newest_version_file_contents = f.read() + + output = diff.render_diff(previous_version_file_contents=previous_version_file_contents, + newest_version_file_contents=newest_version_file_contents) + + output = output.split("\n") + + + self.assertIn('(changed) ok', output) + self.assertIn('(into) xok', output) + self.assertIn('(into) next-x-ok', output) + self.assertIn('(added) and something new', output) + + with open(base_dir + "/test-content/after-2.txt", 'r') as f: + newest_version_file_contents = f.read() + output = diff.render_diff(previous_version_file_contents, newest_version_file_contents) + output = output.split("\n") + self.assertIn('(removed) for having learned computerese,', output) + self.assertIn('(removed) I continue to examine bits, bytes and words', output) + + #diff_removed + with open(base_dir + "/test-content/before.txt", 'r') as f: + previous_version_file_contents = f.read() + + with open(base_dir + "/test-content/after.txt", 'r') as f: + newest_version_file_contents = f.read() + output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False) + output = output.split("\n") + self.assertIn('(changed) ok', output) + self.assertIn('(into) xok', output) + self.assertIn('(into) next-x-ok', output) + self.assertNotIn('(added) and something new', output) + + #diff_removed + with open(base_dir + "/test-content/after-2.txt", 'r') as f: + newest_version_file_contents = f.read() + output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False) + output = output.split("\n") + self.assertIn('(removed) for having learned computerese,', output) + self.assertIn('(removed) I continue to examine bits, bytes and words', output) + + def test_expected_diff_patch_output(self): + base_dir = os.path.dirname(__file__) + with open(base_dir + "/test-content/before.txt", 'r') as f: + before = f.read() + with open(base_dir + "/test-content/after.txt", 'r') as f: + after = f.read() + + output = diff.render_diff(previous_version_file_contents=before, + newest_version_file_contents=after, + patch_format=True) + output = output.split("\n") + + self.assertIn('-ok', output) + self.assertIn('+xok', output) + self.assertIn('+next-x-ok', output) + self.assertIn('+and something new', output) + + # @todo test blocks of changed, blocks of added, blocks of removed + +if __name__ == '__main__': + unittest.main() diff --git a/changedetectionio/tests/unit/test_restock_logic.py b/changedetectionio/tests/unit/test_restock_logic.py new file mode 100644 index 0000000000000000000000000000000000000000..46fff2c7e03a92ec321dc9a8b16d0e7865dc4e43 --- /dev/null +++ b/changedetectionio/tests/unit/test_restock_logic.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +# run from dir above changedetectionio/ dir +# python3 -m unittest changedetectionio.tests.unit.test_restock_logic + +import unittest +import os + +from changedetectionio.processors import restock_diff + +# mostly +class TestDiffBuilder(unittest.TestCase): + + def test_logic(self): + assert restock_diff.is_between(number=10, lower=9, upper=11) == True, "Between 9 and 11" + assert restock_diff.is_between(number=10, lower=0, upper=11) == True, "Between 9 and 11" + assert restock_diff.is_between(number=10, lower=None, upper=11) == True, "Between None and 11" + assert not restock_diff.is_between(number=12, lower=None, upper=11) == True, "12 is not between None and 11" + +if __name__ == '__main__': + unittest.main() diff --git a/changedetectionio/tests/unit/test_scheduler.py b/changedetectionio/tests/unit/test_scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..242a94139e031b99fe7a922f1e72d202dd4e7390 --- /dev/null +++ b/changedetectionio/tests/unit/test_scheduler.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +# run from dir above changedetectionio/ dir +# python3 -m unittest changedetectionio.tests.unit.test_jinja2_security + +import unittest +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +class TestScheduler(unittest.TestCase): + + # UTC+14:00 (Line Islands, Kiribati) is the farthest ahead, always ahead of UTC. + # UTC-12:00 (Baker Island, Howland Island) is the farthest behind, always one calendar day behind UTC. + + def test_timezone_basic_time_within_schedule(self): + from changedetectionio import time_handler + + timezone_str = 'Europe/Berlin' + debug_datetime = datetime.now(ZoneInfo(timezone_str)) + day_of_week = debug_datetime.strftime('%A') + time_str = str(debug_datetime.hour)+':00' + duration = 60 # minutes + + # The current time should always be within 60 minutes of [time_hour]:00 + result = time_handler.am_i_inside_time(day_of_week=day_of_week, + time_str=time_str, + timezone_str=timezone_str, + duration=duration) + + self.assertEqual(result, True, f"{debug_datetime} is within time scheduler {day_of_week} {time_str} in {timezone_str} for {duration} minutes") + + def test_timezone_basic_time_outside_schedule(self): + from changedetectionio import time_handler + + timezone_str = 'Europe/Berlin' + # We try a date in the future.. + debug_datetime = datetime.now(ZoneInfo(timezone_str))+ timedelta(days=-1) + day_of_week = debug_datetime.strftime('%A') + time_str = str(debug_datetime.hour) + ':00' + duration = 60*24 # minutes + + # The current time should always be within 60 minutes of [time_hour]:00 + result = time_handler.am_i_inside_time(day_of_week=day_of_week, + time_str=time_str, + timezone_str=timezone_str, + duration=duration) + + self.assertNotEqual(result, True, + f"{debug_datetime} is NOT within time scheduler {day_of_week} {time_str} in {timezone_str} for {duration} minutes") + + +if __name__ == '__main__': + unittest.main() diff --git a/changedetectionio/tests/unit/test_semver.py b/changedetectionio/tests/unit/test_semver.py new file mode 100644 index 0000000000000000000000000000000000000000..5ebfc95e7c2ca14e8ded3fd17c1422f936da7d15 --- /dev/null +++ b/changedetectionio/tests/unit/test_semver.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 + +# run from dir above changedetectionio/ dir +# python3 -m unittest changedetectionio.tests.unit.test_semver + +import re +import unittest + + +# The SEMVER regex +SEMVER_REGEX = r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" + +# Compile the regex +semver_pattern = re.compile(SEMVER_REGEX) + +class TestSemver(unittest.TestCase): + def test_valid_versions(self): + """Test valid semantic version strings""" + valid_versions = [ + "1.0.0", + "0.1.0", + "0.0.1", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-0.3.7", + "1.0.0-x.7.z.92", + "1.0.0-alpha+001", + "1.0.0+20130313144700", + "1.0.0-beta+exp.sha.5114f85" + ] + for version in valid_versions: + with self.subTest(version=version): + self.assertIsNotNone(semver_pattern.match(version), f"Version {version} should be valid") + + def test_invalid_versions(self): + """Test invalid semantic version strings""" + invalid_versions = [ + "0.48.06", + "1.0", + "1.0.0-", +# Seems to pass the semver.org regex? +# "1.0.0-alpha-", + "1.0.0+", + "1.0.0-alpha+", + "1.0.0-", + "01.0.0", + "1.01.0", + "1.0.01", + ".1.0.0", + "1..0.0" + ] + for version in invalid_versions: + with self.subTest(version=version): + res = semver_pattern.match(version) + self.assertIsNone(res, f"Version '{version}' should be invalid") + + def test_our_version(self): + from changedetectionio import get_version + our_version = get_version() + self.assertIsNotNone(semver_pattern.match(our_version), f"Our version '{our_version}' should be a valid SEMVER string") + + +if __name__ == '__main__': + unittest.main() diff --git a/changedetectionio/tests/unit/test_watch_model.py b/changedetectionio/tests/unit/test_watch_model.py new file mode 100644 index 0000000000000000000000000000000000000000..1856b0ddb585a45a44dab0ebe3026c3e174e317b --- /dev/null +++ b/changedetectionio/tests/unit/test_watch_model.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 + +# run from dir above changedetectionio/ dir +# python3 -m unittest changedetectionio.tests.unit.test_notification_diff + +import unittest +import os + +from changedetectionio.model import Watch + +# mostly +class TestDiffBuilder(unittest.TestCase): + + def test_watch_get_suggested_from_diff_timestamp(self): + import uuid as uuid_builder + watch = Watch.model(datastore_path='/tmp', default={}) + watch.ensure_data_dir_exists() + + + # Contents from the browser are always returned from the browser/requests/etc as str, str is basically UTF-16 in python + watch.save_history_text(contents="hello world", timestamp=100, snapshot_id=str(uuid_builder.uuid4())) + watch.save_history_text(contents="hello world", timestamp=105, snapshot_id=str(uuid_builder.uuid4())) + watch.save_history_text(contents="hello world", timestamp=109, snapshot_id=str(uuid_builder.uuid4())) + watch.save_history_text(contents="hello world", timestamp=112, snapshot_id=str(uuid_builder.uuid4())) + watch.save_history_text(contents="hello world", timestamp=115, snapshot_id=str(uuid_builder.uuid4())) + watch.save_history_text(contents="hello world", timestamp=117, snapshot_id=str(uuid_builder.uuid4())) + + p = watch.get_from_version_based_on_last_viewed + assert p == "100", "Correct 'last viewed' timestamp was detected" + + watch['last_viewed'] = 110 + p = watch.get_from_version_based_on_last_viewed + assert p == "109", "Correct 'last viewed' timestamp was detected" + + watch['last_viewed'] = 116 + p = watch.get_from_version_based_on_last_viewed + assert p == "115", "Correct 'last viewed' timestamp was detected" + + watch['last_viewed'] = 99 + p = watch.get_from_version_based_on_last_viewed + assert p == "100", "When the 'last viewed' timestamp is less than the oldest snapshot, return oldest" + + watch['last_viewed'] = 200 + p = watch.get_from_version_based_on_last_viewed + assert p == "115", "When the 'last viewed' timestamp is greater than the newest snapshot, return second newest" + + watch['last_viewed'] = 109 + p = watch.get_from_version_based_on_last_viewed + assert p == "109", "Correct when its the same time" + + # new empty one + watch = Watch.model(datastore_path='/tmp', default={}) + p = watch.get_from_version_based_on_last_viewed + assert p == None, "None when no history available" + + watch.save_history_text(contents="hello world", timestamp=100, snapshot_id=str(uuid_builder.uuid4())) + p = watch.get_from_version_based_on_last_viewed + assert p == "100", "Correct with only one history snapshot" + + watch['last_viewed'] = 200 + p = watch.get_from_version_based_on_last_viewed + assert p == "100", "Correct with only one history snapshot" + +if __name__ == '__main__': + unittest.main() diff --git a/changedetectionio/tests/util.py b/changedetectionio/tests/util.py new file mode 100644 index 0000000000000000000000000000000000000000..6261b7b22a3e572353c25f117bcf4eef74a92d84 --- /dev/null +++ b/changedetectionio/tests/util.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +from operator import truediv + +from flask import make_response, request +from flask import url_for +import logging +import time + +def set_original_response(): + test_return_data = """ + head title + + Some initial text
    +

    Which is across multiple lines

    +
    + So let's see what happens.
    + + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + return None + +def set_modified_response(): + test_return_data = """ + modified head title + + Some initial text
    +

    which has this one new line

    +
    + So let's see what happens.
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + return None +def set_longer_modified_response(): + test_return_data = """ + modified head title + + Some initial text
    +

    which has this one new line

    +
    + So let's see what happens.
    + So let's see what happens.
    + So let's see what happens.
    + So let's see what happens.
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + return None +def set_more_modified_response(): + test_return_data = """ + modified head title + + Some initial text
    +

    which has this one new line

    +
    + So let's see what happens.
    + Ohh yeah awesome
    + + + """ + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + return None + + +def set_empty_text_response(): + test_return_data = """""" + + with open("test-datastore/endpoint-content.txt", "w") as f: + f.write(test_return_data) + + return None + +def wait_for_notification_endpoint_output(): + '''Apprise can take a few seconds to fire''' + #@todo - could check the apprise object directly instead of looking for this file + from os.path import isfile + for i in range(1, 20): + time.sleep(1) + if isfile("test-datastore/notification.txt"): + return True + + return False + +# kinda funky, but works for now +def get_UUID_for_tag_name(client, name): + app_config = client.application.config.get('DATASTORE').data + for uuid, tag in app_config['settings']['application'].get('tags', {}).items(): + if name == tag.get('title', '').lower().strip(): + return uuid + return None + + +# kinda funky, but works for now +def extract_rss_token_from_UI(client): + import re + res = client.get( + url_for("watchlist.index"), + ) + m = re.search('token=(.+?)"', str(res.data)) + token_key = m.group(1) + return token_key.strip() + +# kinda funky, but works for now +def extract_UUID_from_client(client): + import re + res = client.get( + url_for("watchlist.index"), + ) + # {{api_key}} + + m = re.search('edit/(.+?)[#"]', str(res.data)) + uuid = m.group(1) + return uuid.strip() + + +def wait_for_all_checks(client=None): + """ + Waits until the queue is empty and workers are idle. + Much faster than the original with adaptive timing. + """ + from changedetectionio.flask_app import update_q as global_update_q + from changedetectionio import worker_handler + + logger = logging.getLogger() + empty_since = None + attempt = 0 + max_attempts = 150 # Still reasonable upper bound + + while attempt < max_attempts: + # Start with fast checks, slow down if needed + if attempt < 10: + time.sleep(0.1) # Very fast initial checks + elif attempt < 30: + time.sleep(0.3) # Medium speed + else: + time.sleep(0.8) # Slower for persistent issues + + q_length = global_update_q.qsize() + running_uuids = worker_handler.get_running_uuids() + any_workers_busy = len(running_uuids) > 0 + + if q_length == 0 and not any_workers_busy: + if empty_since is None: + empty_since = time.time() + elif time.time() - empty_since >= 0.15: # Shorter wait + break + else: + empty_since = None + + attempt += 1 + time.sleep(0.3) + +# Replaced by new_live_server_setup and calling per function scope in conftest.py +def live_server_setup(live_server): + return True + +def new_live_server_setup(live_server): + + @live_server.app.route('/test-random-content-endpoint') + def test_random_content_endpoint(): + import secrets + return "Random content - {}\n".format(secrets.token_hex(64)) + + @live_server.app.route('/test-endpoint2') + def test_endpoint2(): + return "some basic content" + + @live_server.app.route('/test-endpoint') + def test_endpoint(): + ctype = request.args.get('content_type') + status_code = request.args.get('status_code') + content = request.args.get('content') or None + + # Used to just try to break the header detection + uppercase_headers = request.args.get('uppercase_headers') + + try: + if content is not None: + resp = make_response(content, status_code) + if uppercase_headers: + ctype=ctype.upper() + resp.headers['CONTENT-TYPE'] = ctype if ctype else 'text/html' + else: + resp.headers['Content-Type'] = ctype if ctype else 'text/html' + return resp + + # Tried using a global var here but didn't seem to work, so reading from a file instead. + with open("test-datastore/endpoint-content.txt", "rb") as f: + resp = make_response(f.read(), status_code) + if uppercase_headers: + resp.headers['CONTENT-TYPE'] = ctype if ctype else 'text/html' + else: + resp.headers['Content-Type'] = ctype if ctype else 'text/html' + return resp + except FileNotFoundError: + return make_response('', status_code) + + # Just return the headers in the request + @live_server.app.route('/test-headers') + def test_headers(): + + output = [] + + for header in request.headers: + output.append("{}:{}".format(str(header[0]), str(header[1]))) + + content = "\n".join(output) + + resp = make_response(content, 200) + resp.headers['server'] = 'custom' + return resp + + # Just return the body in the request + @live_server.app.route('/test-body', methods=['POST', 'GET']) + def test_body(): + print ("TEST-BODY GOT", request.data, "returning") + return request.data + + # Just return the verb in the request + @live_server.app.route('/test-method', methods=['POST', 'GET', 'PATCH']) + def test_method(): + return request.method + + # Where we POST to as a notification, also use a space here to test URL escaping is OK across all tests that use this. ( #2868 ) + @live_server.app.route('/test_notification endpoint', methods=['POST', 'GET']) + def test_notification_endpoint(): + + with open("test-datastore/notification.txt", "wb") as f: + # Debug method, dump all POST to file also, used to prove #65 + data = request.stream.read() + if data != None: + f.write(data) + + with open("test-datastore/notification-url.txt", "w") as f: + f.write(request.url) + + with open("test-datastore/notification-headers.txt", "w") as f: + f.write(str(request.headers)) + + if request.content_type: + with open("test-datastore/notification-content-type.txt", "w") as f: + f.write(request.content_type) + + print("\n>> Test notification endpoint was hit.\n", data) + + content = "Text was set" + status_code = request.args.get('status_code',200) + resp = make_response(content, status_code) + return resp + + # Just return the verb in the request + @live_server.app.route('/test-basicauth', methods=['GET']) + def test_basicauth_method(): + auth = request.authorization + ret = " ".join([auth.username, auth.password, auth.type]) + return ret + + # Just return some GET var + @live_server.app.route('/test-return-query', methods=['GET']) + def test_return_query(): + return request.query_string + + + @live_server.app.route('/endpoint-test.pdf') + def test_pdf_endpoint(): + + # Tried using a global var here but didn't seem to work, so reading from a file instead. + with open("test-datastore/endpoint-test.pdf", "rb") as f: + resp = make_response(f.read(), 200) + resp.headers['Content-Type'] = 'application/pdf' + return resp + + @live_server.app.route('/test-interactive-html-endpoint') + def test_interactive_html_endpoint(): + header_text="" + for k,v in request.headers.items(): + header_text += f"{k}: {v}
    " + + resp = make_response(f""" + + + Primitive JS check for
    changedetectionio/tests/visualselector/test_fetch_data.py
    +

    This text should be removed

    +
    + + + +
    + +
    +                {header_text.lower()}
    +                
    + +
    + +
    +
    Waiting to reflect text from #test-input-text here
    +
    + + + """, 200) + resp.headers['Content-Type'] = 'text/html' + return resp + + live_server.start() + diff --git a/changedetectionio/tests/visualselector/__init__.py b/changedetectionio/tests/visualselector/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..085b3d78caa449246fa27bc8ebf3559ed6380577 --- /dev/null +++ b/changedetectionio/tests/visualselector/__init__.py @@ -0,0 +1,2 @@ +"""Tests for the app.""" + diff --git a/changedetectionio/tests/visualselector/conftest.py b/changedetectionio/tests/visualselector/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..9a4bf56bccae80b022fe170097d9accefd8f9f1f --- /dev/null +++ b/changedetectionio/tests/visualselector/conftest.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 + +from .. import conftest diff --git a/changedetectionio/tests/visualselector/test_fetch_data.py b/changedetectionio/tests/visualselector/test_fetch_data.py new file mode 100644 index 0000000000000000000000000000000000000000..c476f7db2564cd22d8b1cd5a29bb85f8685303e7 --- /dev/null +++ b/changedetectionio/tests/visualselector/test_fetch_data.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 + +import os +from flask import url_for +from ..util import live_server_setup, wait_for_all_checks + +# def test_setup(client, live_server): + # live_server_setup(live_server) # Setup on conftest per function + + +# Add a site in paused mode, add an invalid filter, we should still have visual selector data ready +def test_visual_selector_content_ready(client, live_server, measure_memory_usage): + + import os + import json + + assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" + + # Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url + test_url = url_for('test_interactive_html_endpoint', _external=True) + test_url = test_url.replace('localhost.localdomain', 'cdio') + test_url = test_url.replace('localhost', 'cdio') + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + assert b"Watch added in Paused state, saving will unpause" in res.data + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + res = client.post( + url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1), + data={ + "url": test_url, + "tags": "", + # For now, cookies doesnt work in headers because it must be a full cookiejar object + 'headers': "testheader: yes\buser-agent: MyCustomAgent", + 'fetch_backend': "html_webdriver", + }, + follow_redirects=True + ) + assert b"unpaused" in res.data + wait_for_all_checks(client) + + + assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, "Watch history had atleast 1 (everything fetched OK)" + + res = client.get( + url_for("ui.ui_views.preview_page", uuid=uuid), + follow_redirects=True + ) + assert b"testheader: yes" in res.data + assert b"user-agent: mycustomagent" in res.data + + + assert os.path.isfile(os.path.join('test-datastore', uuid, 'last-screenshot.png')), "last-screenshot.png should exist" + assert os.path.isfile(os.path.join('test-datastore', uuid, 'elements.deflate')), "xpath elements.deflate data should exist" + + # Open it and see if it roughly looks correct + with open(os.path.join('test-datastore', uuid, 'elements.deflate'), 'rb') as f: + import zlib + compressed_data = f.read() + decompressed_data = zlib.decompress(compressed_data) + # See if any error was thrown + json_data = json.loads(decompressed_data.decode('utf-8')) + + # Attempt to fetch it via the web hook that the browser would use + res = client.get(url_for('static_content', group='visual_selector_data', filename=uuid)) + decompressed_data = zlib.decompress(res.data) + json_data = json.loads(decompressed_data.decode('utf-8')) + + assert res.mimetype == 'application/json' + assert res.status_code == 200 + + + # Some options should be enabled + # @todo - in the future, the visibility should be toggled by JS from the request type setting + res = client.get( + url_for("ui.ui_edit.edit_page", uuid="first"), + follow_redirects=True + ) + assert b'notification_screenshot' in res.data + client.get( + url_for("ui.form_delete", uuid="all"), + follow_redirects=True + ) + +def test_basic_browserstep(client, live_server, measure_memory_usage): + + assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" + + test_url = url_for('test_interactive_html_endpoint', _external=True) + test_url = test_url.replace('localhost.localdomain', 'cdio') + test_url = test_url.replace('localhost', 'cdio') + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + + assert b"Watch added in Paused state, saving will unpause" in res.data + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), + data={ + "url": test_url, + "tags": "", + 'fetch_backend': "html_webdriver", + 'browser_steps-0-operation': 'Enter text in field', + 'browser_steps-0-selector': '#test-input-text', + # Should get set to the actual text (jinja2 rendered) + 'browser_steps-0-optional_value': "Hello-Jinja2-{% now 'Europe/Berlin', '%Y-%m-%d' %}", + 'browser_steps-1-operation': 'Click element', + 'browser_steps-1-selector': 'button[name=test-button]', + 'browser_steps-1-optional_value': '', + # For now, cookies doesnt work in headers because it must be a full cookiejar object + 'headers': "testheader: yes\buser-agent: MyCustomAgent", + }, + follow_redirects=True + ) + assert b"unpaused" in res.data + wait_for_all_checks(client) + + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, "Watch history had atleast 1 (everything fetched OK)" + + assert b"This text should be removed" not in res.data + + # Check HTML conversion detected and workd + res = client.get( + url_for("ui.ui_views.preview_page", uuid=uuid), + follow_redirects=True + ) + assert b"This text should be removed" not in res.data + assert b"I smell JavaScript because the button was pressed" in res.data + + assert b'Hello-Jinja2-20' in res.data + + assert b"testheader: yes" in res.data + assert b"user-agent: mycustomagent" in res.data + +def test_non_200_errors_report_browsersteps(client, live_server): + + + four_o_four_url = url_for('test_endpoint', status_code=404, _external=True) + four_o_four_url = four_o_four_url.replace('localhost.localdomain', 'cdio') + four_o_four_url = four_o_four_url.replace('localhost', 'cdio') + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": four_o_four_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + + assert b"Watch added in Paused state, saving will unpause" in res.data + assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" + + uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) + + # now test for 404 errors + res = client.post( + url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1), + data={ + "url": four_o_four_url, + "tags": "", + 'fetch_backend': "html_webdriver", + 'browser_steps-0-operation': 'Click element', + 'browser_steps-0-selector': 'button[name=test-button]', + 'browser_steps-0-optional_value': '' + }, + follow_redirects=True + ) + assert b"unpaused" in res.data + + wait_for_all_checks(client) + + res = client.get(url_for("watchlist.index")) + + assert b'Error - 404' in res.data + + client.get( + url_for("ui.form_delete", uuid="all"), + follow_redirects=True + ) diff --git a/changedetectionio/time_handler.py b/changedetectionio/time_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..ba071f40a84e14c1384dab32a48e83150d2d50c7 --- /dev/null +++ b/changedetectionio/time_handler.py @@ -0,0 +1,105 @@ +from datetime import timedelta, datetime +from enum import IntEnum +from zoneinfo import ZoneInfo + + +class Weekday(IntEnum): + """Enumeration for days of the week.""" + Monday = 0 + Tuesday = 1 + Wednesday = 2 + Thursday = 3 + Friday = 4 + Saturday = 5 + Sunday = 6 + + +def am_i_inside_time( + day_of_week: str, + time_str: str, + timezone_str: str, + duration: int = 15, +) -> bool: + """ + Determines if the current time falls within a specified time range. + + Parameters: + day_of_week (str): The day of the week (e.g., 'Monday'). + time_str (str): The start time in 'HH:MM' format. + timezone_str (str): The timezone identifier (e.g., 'Europe/Berlin'). + duration (int, optional): The duration of the time range in minutes. Default is 15. + + Returns: + bool: True if the current time is within the time range, False otherwise. + """ + # Parse the target day of the week + try: + target_weekday = Weekday[day_of_week.capitalize()] + except KeyError: + raise ValueError(f"Invalid day_of_week: '{day_of_week}'. Must be a valid weekday name.") + + # Parse the start time + try: + target_time = datetime.strptime(time_str, '%H:%M').time() + except ValueError: + raise ValueError(f"Invalid time_str: '{time_str}'. Must be in 'HH:MM' format.") + + # Define the timezone + try: + tz = ZoneInfo(timezone_str) + except Exception: + raise ValueError(f"Invalid timezone_str: '{timezone_str}'. Must be a valid timezone identifier.") + + # Get the current time in the specified timezone + now_tz = datetime.now(tz) + + # Check if the current day matches the target day or overlaps due to duration + current_weekday = now_tz.weekday() + start_datetime_tz = datetime.combine(now_tz.date(), target_time, tzinfo=tz) + + # Handle previous day's overlap + if target_weekday == (current_weekday - 1) % 7: + # Calculate start and end times for the overlap from the previous day + start_datetime_tz -= timedelta(days=1) + end_datetime_tz = start_datetime_tz + timedelta(minutes=duration) + if start_datetime_tz <= now_tz < end_datetime_tz: + return True + + # Handle current day's range + if target_weekday == current_weekday: + end_datetime_tz = start_datetime_tz + timedelta(minutes=duration) + if start_datetime_tz <= now_tz < end_datetime_tz: + return True + + # Handle next day's overlap + if target_weekday == (current_weekday + 1) % 7: + end_datetime_tz = start_datetime_tz + timedelta(minutes=duration) + if now_tz < start_datetime_tz and now_tz + timedelta(days=1) < end_datetime_tz: + return True + + return False + + +def is_within_schedule(time_schedule_limit, default_tz="UTC"): + if time_schedule_limit and time_schedule_limit.get('enabled'): + # Get the timezone the time schedule is in, so we know what day it is there + tz_name = time_schedule_limit.get('timezone') + if not tz_name: + tz_name = default_tz + + now_day_name_in_tz = datetime.now(ZoneInfo(tz_name.strip())).strftime('%A') + selected_day_schedule = time_schedule_limit.get(now_day_name_in_tz.lower()) + if not selected_day_schedule.get('enabled'): + return False + + duration = selected_day_schedule.get('duration') + selected_day_run_duration_m = int(duration.get('hours')) * 60 + int(duration.get('minutes')) + + is_valid = am_i_inside_time(day_of_week=now_day_name_in_tz, + time_str=selected_day_schedule['start_time'], + timezone_str=tz_name, + duration=selected_day_run_duration_m) + + return is_valid + + return False diff --git a/changedetectionio/worker_handler.py b/changedetectionio/worker_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..953d23544c14724afc1a35966064a1f3b80c2d4c --- /dev/null +++ b/changedetectionio/worker_handler.py @@ -0,0 +1,395 @@ +""" +Worker management module for changedetection.io + +Handles asynchronous workers for dynamic worker scaling. +Sync worker support has been removed in favor of async-only architecture. +""" + +import asyncio +import os +import threading +import time +from loguru import logger + +# Global worker state +running_async_tasks = [] +async_loop = None +async_loop_thread = None + +# Track currently processing UUIDs for async workers +currently_processing_uuids = set() + +# Configuration - async workers only +USE_ASYNC_WORKERS = True + + +def start_async_event_loop(): + """Start a dedicated event loop for async workers in a separate thread""" + global async_loop + logger.info("Starting async event loop for workers") + + try: + # Create a new event loop for this thread + async_loop = asyncio.new_event_loop() + # Set it as the event loop for this thread + asyncio.set_event_loop(async_loop) + + logger.debug(f"Event loop created and set: {async_loop}") + + # Run the event loop forever + async_loop.run_forever() + except Exception as e: + logger.error(f"Async event loop error: {e}") + finally: + # Clean up + if async_loop and not async_loop.is_closed(): + async_loop.close() + async_loop = None + logger.info("Async event loop stopped") + + +def start_async_workers(n_workers, update_q, notification_q, app, datastore): + """Start the async worker management system""" + global async_loop_thread, async_loop, running_async_tasks, currently_processing_uuids + + # Clear any stale UUID tracking state + currently_processing_uuids.clear() + + # Start the event loop in a separate thread + async_loop_thread = threading.Thread(target=start_async_event_loop, daemon=True) + async_loop_thread.start() + + # Wait for the loop to be available (with timeout for safety) + max_wait_time = 5.0 + wait_start = time.time() + while async_loop is None and (time.time() - wait_start) < max_wait_time: + time.sleep(0.1) + + if async_loop is None: + logger.error("Failed to start async event loop within timeout") + return + + # Additional brief wait to ensure loop is running + time.sleep(0.2) + + # Start async workers + logger.info(f"Starting {n_workers} async workers") + for i in range(n_workers): + try: + # Use a factory function to create named worker coroutines + def create_named_worker(worker_id): + async def named_worker(): + task = asyncio.current_task() + if task: + task.set_name(f"async-worker-{worker_id}") + return await start_single_async_worker(worker_id, update_q, notification_q, app, datastore) + return named_worker() + + task_future = asyncio.run_coroutine_threadsafe(create_named_worker(i), async_loop) + running_async_tasks.append(task_future) + except RuntimeError as e: + logger.error(f"Failed to start async worker {i}: {e}") + continue + + +async def start_single_async_worker(worker_id, update_q, notification_q, app, datastore): + """Start a single async worker with auto-restart capability""" + from changedetectionio.async_update_worker import async_update_worker + + # Check if we're in pytest environment - if so, be more gentle with logging + import os + in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ + + while not app.config.exit.is_set(): + try: + if not in_pytest: + logger.info(f"Starting async worker {worker_id}") + await async_update_worker(worker_id, update_q, notification_q, app, datastore) + # If we reach here, worker exited cleanly + if not in_pytest: + logger.info(f"Async worker {worker_id} exited cleanly") + break + except asyncio.CancelledError: + # Task was cancelled (normal shutdown) + if not in_pytest: + logger.info(f"Async worker {worker_id} cancelled") + break + except Exception as e: + logger.error(f"Async worker {worker_id} crashed: {e}") + if not in_pytest: + logger.info(f"Restarting async worker {worker_id} in 5 seconds...") + await asyncio.sleep(5) + + if not in_pytest: + logger.info(f"Async worker {worker_id} shutdown complete") + + +def start_workers(n_workers, update_q, notification_q, app, datastore): + """Start async workers - sync workers are deprecated""" + start_async_workers(n_workers, update_q, notification_q, app, datastore) + + +def add_worker(update_q, notification_q, app, datastore): + """Add a new async worker (for dynamic scaling)""" + global running_async_tasks + + if not async_loop: + logger.error("Async loop not running, cannot add worker") + return False + + worker_id = len(running_async_tasks) + logger.info(f"Adding async worker {worker_id}") + + task_future = asyncio.run_coroutine_threadsafe( + start_single_async_worker(worker_id, update_q, notification_q, app, datastore), async_loop + ) + running_async_tasks.append(task_future) + return True + + +def remove_worker(): + """Remove an async worker (for dynamic scaling)""" + global running_async_tasks + + if not running_async_tasks: + return False + + # Cancel the last worker + task_future = running_async_tasks.pop() + task_future.cancel() + logger.info(f"Removed async worker, {len(running_async_tasks)} workers remaining") + return True + + +def get_worker_count(): + """Get current number of async workers""" + return len(running_async_tasks) + + +def get_running_uuids(): + """Get list of UUIDs currently being processed by async workers""" + return list(currently_processing_uuids) + + +def set_uuid_processing(uuid, processing=True): + """Mark a UUID as being processed or completed""" + global currently_processing_uuids + if processing: + currently_processing_uuids.add(uuid) + logger.debug(f"Started processing UUID: {uuid}") + else: + currently_processing_uuids.discard(uuid) + logger.debug(f"Finished processing UUID: {uuid}") + + +def is_watch_running(watch_uuid): + """Check if a specific watch is currently being processed""" + return watch_uuid in get_running_uuids() + + +def queue_item_async_safe(update_q, item): + """Queue an item for async queue processing""" + if async_loop and not async_loop.is_closed(): + try: + # For async queue, schedule the put operation + asyncio.run_coroutine_threadsafe(update_q.put(item), async_loop) + except RuntimeError as e: + logger.error(f"Failed to queue item: {e}") + else: + logger.error("Async loop not available or closed for queueing item") + + +def shutdown_workers(): + """Shutdown all async workers fast and aggressively""" + global async_loop, async_loop_thread, running_async_tasks + + # Check if we're in pytest environment - if so, be more gentle with logging + import os + in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ + + if not in_pytest: + logger.info("Fast shutdown of async workers initiated...") + + # Cancel all async tasks immediately + for task_future in running_async_tasks: + if not task_future.done(): + task_future.cancel() + + # Stop the async event loop immediately + if async_loop and not async_loop.is_closed(): + try: + async_loop.call_soon_threadsafe(async_loop.stop) + except RuntimeError: + # Loop might already be stopped + pass + + running_async_tasks.clear() + async_loop = None + + # Give async thread minimal time to finish, then continue + if async_loop_thread and async_loop_thread.is_alive(): + async_loop_thread.join(timeout=1.0) # Only 1 second timeout + if async_loop_thread.is_alive() and not in_pytest: + logger.info("Async thread still running after timeout - continuing with shutdown") + async_loop_thread = None + + if not in_pytest: + logger.info("Async workers fast shutdown complete") + + + + +def adjust_async_worker_count(new_count, update_q=None, notification_q=None, app=None, datastore=None): + """ + Dynamically adjust the number of async workers. + + Args: + new_count: Target number of workers + update_q, notification_q, app, datastore: Required for adding new workers + + Returns: + dict: Status of the adjustment operation + """ + global running_async_tasks + + current_count = get_worker_count() + + if new_count == current_count: + return { + 'status': 'no_change', + 'message': f'Worker count already at {current_count}', + 'current_count': current_count + } + + if new_count > current_count: + # Add workers + workers_to_add = new_count - current_count + logger.info(f"Adding {workers_to_add} async workers (from {current_count} to {new_count})") + + if not all([update_q, notification_q, app, datastore]): + return { + 'status': 'error', + 'message': 'Missing required parameters to add workers', + 'current_count': current_count + } + + for i in range(workers_to_add): + worker_id = len(running_async_tasks) + task_future = asyncio.run_coroutine_threadsafe( + start_single_async_worker(worker_id, update_q, notification_q, app, datastore), + async_loop + ) + running_async_tasks.append(task_future) + + return { + 'status': 'success', + 'message': f'Added {workers_to_add} workers', + 'previous_count': current_count, + 'current_count': new_count + } + + else: + # Remove workers + workers_to_remove = current_count - new_count + logger.info(f"Removing {workers_to_remove} async workers (from {current_count} to {new_count})") + + removed_count = 0 + for _ in range(workers_to_remove): + if running_async_tasks: + task_future = running_async_tasks.pop() + task_future.cancel() + # Wait for the task to actually stop + try: + task_future.result(timeout=5) # 5 second timeout + except Exception: + pass # Task was cancelled, which is expected + removed_count += 1 + + return { + 'status': 'success', + 'message': f'Removed {removed_count} workers', + 'previous_count': current_count, + 'current_count': current_count - removed_count + } + + +def get_worker_status(): + """Get status information about async workers""" + return { + 'worker_type': 'async', + 'worker_count': get_worker_count(), + 'running_uuids': get_running_uuids(), + 'async_loop_running': async_loop is not None, + } + + +def check_worker_health(expected_count, update_q=None, notification_q=None, app=None, datastore=None): + """ + Check if the expected number of async workers are running and restart any missing ones. + + Args: + expected_count: Expected number of workers + update_q, notification_q, app, datastore: Required for restarting workers + + Returns: + dict: Health check results + """ + global running_async_tasks + + current_count = get_worker_count() + + if current_count == expected_count: + return { + 'status': 'healthy', + 'expected_count': expected_count, + 'actual_count': current_count, + 'message': f'All {expected_count} async workers running' + } + + # Check for crashed async workers + dead_workers = [] + alive_count = 0 + + for i, task_future in enumerate(running_async_tasks[:]): + if task_future.done(): + try: + result = task_future.result() + dead_workers.append(i) + logger.warning(f"Async worker {i} completed unexpectedly") + except Exception as e: + dead_workers.append(i) + logger.error(f"Async worker {i} crashed: {e}") + else: + alive_count += 1 + + # Remove dead workers from tracking + for i in reversed(dead_workers): + if i < len(running_async_tasks): + running_async_tasks.pop(i) + + missing_workers = expected_count - alive_count + restarted_count = 0 + + if missing_workers > 0 and all([update_q, notification_q, app, datastore]): + logger.info(f"Restarting {missing_workers} crashed async workers") + + for i in range(missing_workers): + worker_id = alive_count + i + try: + task_future = asyncio.run_coroutine_threadsafe( + start_single_async_worker(worker_id, update_q, notification_q, app, datastore), + async_loop + ) + running_async_tasks.append(task_future) + restarted_count += 1 + except Exception as e: + logger.error(f"Failed to restart worker {worker_id}: {e}") + + return { + 'status': 'repaired' if restarted_count > 0 else 'degraded', + 'expected_count': expected_count, + 'actual_count': alive_count, + 'dead_workers': len(dead_workers), + 'restarted_workers': restarted_count, + 'message': f'Found {len(dead_workers)} dead workers, restarted {restarted_count}' + } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..1204742e8288baa49118ed49a62edff0a2a2da2b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,125 @@ +services: + changedetection: + image: ghcr.io/dgtlmoon/changedetection.io + container_name: changedetection + hostname: changedetection + volumes: + - changedetection-data:/datastore + # Configurable proxy list support, see https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#proxy-list-support + # - ./proxies.json:/datastore/proxies.json + + # environment: + # Default listening port, can also be changed with the -p option (not to be confused with ports: below) + # - PORT=7860 + # + # Log levels are in descending order. (TRACE is the most detailed one) + # Log output levels: TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL + # - LOGGER_LEVEL=TRACE + # + # + # Uncomment below and the "sockpuppetbrowser" to use a real Chrome browser (It uses the "playwright" protocol) + # - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000 + # + # + # Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well) + # - WEBDRIVER_URL=http://browser-selenium-chrome:4444/wd/hub + # + # WebDriver proxy settings webdriver_proxyType, webdriver_ftpProxy, webdriver_noProxy, + # webdriver_proxyAutoconfigUrl, webdriver_autodetect, + # webdriver_socksProxy, webdriver_socksUsername, webdriver_socksVersion, webdriver_socksPassword + # + # https://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.common.proxy + # + # + # Playwright proxy settings playwright_proxy_server, playwright_proxy_bypass, playwright_proxy_username, playwright_proxy_password + # + # https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch-option-proxy + # + # Plain requests - proxy support example. + # - HTTP_PROXY=socks5h://10.10.1.10:1080 + # - HTTPS_PROXY=socks5h://10.10.1.10:1080 + # + # An exclude list (useful for notification URLs above) can be specified by with + # - NO_PROXY="localhost,192.168.0.0/24" + # + # Base URL of your changedetection.io install (Added to the notification alert) + # - BASE_URL=https://mysite.com + # Respect proxy_pass type settings, `proxy_set_header Host "localhost";` and `proxy_set_header X-Forwarded-Prefix /app;` + # More here https://github.com/dgtlmoon/changedetection.io/wiki/Running-changedetection.io-behind-a-reverse-proxy + # - USE_X_SETTINGS=1 + # + # Hides the `Referer` header so that monitored websites can't see the changedetection.io hostname. + # - HIDE_REFERER=true + # + # Default number of parallel/concurrent fetchers + # - FETCH_WORKERS=10 + # + # Absolute minimum seconds to recheck, overrides any watch minimum, change to 0 to disable + # - MINIMUM_SECONDS_RECHECK_TIME=3 + # + # If you want to watch local files file:///path/to/file.txt (careful! security implications!) + # - ALLOW_FILE_URI=False + # + # For complete privacy if you don't want to use the 'check version' / telemetry service + # - DISABLE_VERSION_CHECK=true + # + # A valid timezone name to run as (for scheduling watch checking) see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + # - TZ=America/Los_Angeles + # + # Maximum height of screenshots, default is 16000 px, screenshots will be clipped to this if exceeded. + # RAM usage will be higher if you increase this. + # - SCREENSHOT_MAX_HEIGHT=16000 + + # Comment out ports: when using behind a reverse proxy , enable networks: etc. + ports: + - 127.0.0.1:7860:7860 + restart: unless-stopped + + # Used for fetching pages via WebDriver+Chrome where you need Javascript support. + # Now working on arm64 (needs testing on rPi - tested on Oracle ARM instance) + # replace image with seleniarm/standalone-chromium:4.0.0-20211213 + + # If WEBDRIVER or PLAYWRIGHT are enabled, changedetection container depends on that + # and must wait before starting (substitute "browser-chrome" with "playwright-chrome" if last one is used) +# depends_on: +# browser-sockpuppet-chrome: +# condition: service_started + +# Sockpuppetbrowser is basically chrome wrapped in an API for allowing fast fetching of web-pages. +# RECOMMENDED FOR FETCHING PAGES WITH CHROME, be sure to enable the "PLAYWRIGHT_DRIVER_URL" env variable in the main changedetection container +# browser-sockpuppet-chrome: +# hostname: browser-sockpuppet-chrome +# image: dgtlmoon/sockpuppetbrowser:latest +# cap_add: +# - SYS_ADMIN +## SYS_ADMIN might be too much, but it can be needed on your platform https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#running-puppeteer-on-gitlabci +# restart: unless-stopped +# environment: +# - SCREEN_WIDTH=1920 +# - SCREEN_HEIGHT=1024 +# - SCREEN_DEPTH=16 +# - MAX_CONCURRENT_CHROME_PROCESSES=10 + +# Used for fetching pages via Playwright+Chrome where you need Javascript support. +# Note: Works well but is deprecated, does not fetch full page screenshots (doesnt work with Visual Selector) +# Does not report status codes (200, 404, 403) and other issues +# browser-selenium-chrome: +# hostname: browser-selenium-chrome +# image: selenium/standalone-chrome:4 +# environment: +# - VNC_NO_PASSWORD=1 +# - SCREEN_WIDTH=1920 +# - SCREEN_HEIGHT=1080 +# - SCREEN_DEPTH=24 +# CHROME_OPTIONS: | +# --window-size=1280,1024 +# --headless +# --disable-gpu +# volumes: +# # Workaround to avoid the browser crashing inside a docker container +# # See https://github.com/SeleniumHQ/docker-selenium#quick-start +# - /dev/shm:/dev/shm +# restart: unless-stopped + +volumes: + changedetection-data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..2e90ccc37d9ec84676359428f944e6a7bbffcb25 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,134 @@ +# eventlet>=0.38.0 # Removed - replaced with threading mode for better Python 3.12+ compatibility +feedgen~=0.9 +flask-compress +# 0.6.3 included compatibility fix for werkzeug 3.x (2.x had deprecation of url handlers) +flask-login>=0.6.3 +flask-paginate +flask_expects_json~=1.7 +flask_restful +flask_cors # For the Chrome extension to operate +flask_wtf~=1.2 +flask~=2.3 +flask-socketio~=5.5.1 +python-socketio~=5.13.0 +python-engineio~=4.12.0 +inscriptis~=2.2 +pytz +timeago~=1.0 +validators~=0.21 + + +# Set these versions together to avoid a RequestsDependencyWarning +# >= 2.26 also adds Brotli support if brotli is installed +brotli~=1.0 +requests[socks] +requests-file + +# urllib3==1.26.19 # Unpinned - let requests decide compatible version +# If specific version needed for security, use urllib3>=1.26.19,<3.0 +chardet>2.3.0 + +wtforms~=3.0 +jsonpath-ng~=1.5.3 + +# dnspython - Used by paho-mqtt for MQTT broker resolution +# Version pin removed since eventlet (which required the specific 2.6.1 pin) has been eliminated +# paho-mqtt will install compatible dnspython version automatically + +# jq not available on Windows so must be installed manually + +# Notification library +apprise==1.9.3 + +# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 +# use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814 +paho-mqtt!=2.0.* + +# Requires extra wheel for rPi +cryptography~=42.0.8 + +# Used for CSS filtering +beautifulsoup4>=4.0.0 + +# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe. +# #2328 - 5.2.0 and 5.2.1 had extra CPU flag CFLAGS set which was not compatible on older hardware +# It could be advantageous to run its own pypi package here with those performance flags set +# https://bugs.launchpad.net/lxml/+bug/2059910/comments/16 +lxml >=4.8.0,<6,!=5.2.0,!=5.2.1 + +# XPath 2.0-3.1 support - 4.2.0 had issues, 4.1.5 stable +# Consider updating to latest stable version periodically +elementpath==4.1.5 + +selenium~=4.31.0 + +# https://github.com/pallets/werkzeug/issues/2985 +# Maybe related to pytest? +werkzeug==3.0.6 + +# Templating, so far just in the URLs but in the future can be for the notifications also +jinja2~=3.1 +jinja2-time +openpyxl +# https://peps.python.org/pep-0508/#environment-markers +# https://github.com/dgtlmoon/changedetection.io/pull/1009 +jq~=1.3; python_version >= "3.8" and sys_platform == "darwin" +jq~=1.3; python_version >= "3.8" and sys_platform == "linux" + +# playwright is installed at Dockerfile build time because it's not available on all platforms + +pyppeteer-ng==2.0.0rc10 + +pyppeteerstealth>=0.0.4 + +# Include pytest, so if theres a support issue we can ask them to run these tests on their setup +pytest ~=7.2 +pytest-flask ~=1.2 + +# Anything 4.0 and up but not 5.0 +jsonschema ~= 4.0 + + +loguru + +# For scraping all possible metadata relating to products so we can do better restock detection +extruct + +# For cleaning up unknown currency formats +babel + +levenshtein + +# Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096 +greenlet >= 3.0.3 + +# Optional: Used for high-concurrency SocketIO mode (via SOCKETIO_MODE=gevent) +# Note: gevent has cross-platform limitations (Windows 1024 socket limit, macOS ARM build issues) +# Default SOCKETIO_MODE=threading is recommended for better compatibility +gevent + +# Pinned or it causes problems with flask_expects_json which seems unmaintained +referencing==0.35.1 + +# For conditions +panzi-json-logic +# For conditions - extracted number from a body of text +price-parser + +# flask_socket_io - incorrect package name, already have flask-socketio above + +# Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !) +tzdata + +#typing_extensions ==4.8.0 + +pluggy ~= 1.5 + +# Needed for testing, cross-platform for process and system monitoring +psutil==7.0.0 + +ruff >= 0.11.2 +pre_commit >= 4.2.0 + +# For events between checking and socketio updates +blinker diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000000000000000000000000000000000000..d5831c54fb311d1195992b408e9fd04e302e6598 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.11.5 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..4767e845d287d6e376d5d358a7e1411cbd4e9dca --- /dev/null +++ b/setup.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +import codecs +import os.path +import re +import sys + +from setuptools import setup, find_packages + +here = os.path.abspath(os.path.dirname(__file__)) + + +def read(*parts): + return codecs.open(os.path.join(here, *parts), 'r').read() + + +def find_version(*file_paths): + version_file = read(*file_paths) + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", + version_file, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError("Unable to find version string.") + + +install_requires = open('requirements.txt').readlines() + +setup( + name='changedetection.io', + version=find_version("changedetectionio", "__init__.py"), + description='Website change detection and monitoring service, detect changes to web pages and send alerts/notifications.', + long_description=open('README-pip.md').read(), + long_description_content_type='text/markdown', + keywords='website change monitor for changes notification change detection ' + 'alerts tracking website tracker change alert website and monitoring', + entry_points={"console_scripts": ["changedetection.io=changedetectionio:main"]}, + zip_safe=True, + scripts=["changedetection.py"], + author='dgtlmoon', + url='https://changedetection.io', + packages=['changedetectionio'], + include_package_data=True, + install_requires=install_requires, + license="Apache License 2.0", + python_requires=">= 3.10", + classifiers=['Intended Audience :: Customer Service', + 'Intended Audience :: Developers', + 'Intended Audience :: Education', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Financial and Insurance Industry', + 'Intended Audience :: Healthcare Industry', + 'Intended Audience :: Information Technology', + 'Intended Audience :: Legal Industry', + 'Intended Audience :: Manufacturing', + 'Intended Audience :: Other Audience', + 'Intended Audience :: Religion', + 'Intended Audience :: Science/Research', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Telecommunications Industry', + 'Topic :: Education', + 'Topic :: Internet', + 'Topic :: Internet :: WWW/HTTP :: Indexing/Search', + 'Topic :: Internet :: WWW/HTTP :: Site Management', + 'Topic :: Internet :: WWW/HTTP :: Site Management :: Link Checking', + 'Topic :: Internet :: WWW/HTTP :: Browsers', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Office/Business', + 'Topic :: Other/Nonlisted Topic', + 'Topic :: Scientific/Engineering :: Information Analysis', + 'Topic :: Text Processing :: Markup :: HTML', + 'Topic :: Utilities' + ], +)