Compare commits

..

35 Commits

Author SHA1 Message Date
dgtlmoon 59ce32fa4c tweaks 2025-09-16 18:26:20 +02:00
dgtlmoon 48962c0226 more 2025-09-16 18:08:25 +02:00
dgtlmoon 1039a1e590 woops 2025-09-16 18:03:46 +02:00
dgtlmoon 8688f9ecaa more 2025-09-16 17:56:18 +02:00
dgtlmoon 511f0c4853 Fix 2025-09-16 17:29:50 +02:00
dgtlmoon b598c742f2 adding test 2025-09-16 17:28:36 +02:00
dgtlmoon 773cd803d6 logic tweak 2025-09-16 17:21:26 +02:00
dgtlmoon 582232b02f Add missing val 2025-09-16 17:14:09 +02:00
dgtlmoon 2c6b36e788 Adding flag 2025-09-16 16:48:52 +02:00
dgtlmoon ca40b2ea7a Adding output 2025-09-16 16:34:58 +02:00
dgtlmoon 2e631c93c4 recheck time field validation 2025-09-16 16:33:00 +02:00
dgtlmoon a27265450c 0.50.13
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-09-15 13:51:05 +02:00
dgtlmoon cc5455c3dc API - OpenAPI call validation was being skipped on docker based installs, misc API fixes (#3424) 2025-09-15 13:50:29 +02:00
dgtlmoon 9db7fb83eb Always extract page <title>, {{watch_title}} added to notification body tokens (#3415)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-09-10 14:52:41 +02:00
dgtlmoon f0061110c9 UI - Correctly set 'checking now' status badge on edit page 2025-09-10 12:55:22 +02:00
Chris Johnson a13fedc0d6 Add noindex meta (#3416)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-09-09 15:11:58 +02:00
dependabot[bot] 7576bec66a Build - Bump actions/setup-python from 5 to 6 in the all group (#3408)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
2025-09-08 12:12:04 +02:00
Nils Bergmann 7672190923 Restock - Add 'nicht mehr lieferbar' to stock status checks (#3410) 2025-09-08 12:11:37 +02:00
dgtlmoon 0ade4307b0 0.50.12
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-09-07 16:15:16 +02:00
dgtlmoon 8c03b65dc6 Fix - Filters in tags/groups were being added to watches on each check - #3406 fix list update (#3407) 2025-09-07 15:33:18 +02:00
Jeff Hedlund 8a07459e43 UI - Added "unread" view filter (#3393)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-09-06 11:47:57 +02:00
Giuseppe Rota cd8e115118 Enable "last_viewed" field in the watch API. (#3403) 2025-09-06 11:47:16 +02:00
dgtlmoon 4ff7b20fcf Update docker-compose.yml - Include mac port info warning
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-08-29 13:15:59 +02:00
dgtlmoon 8120f00148 0.50.11
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
2025-08-28 22:11:36 +02:00
dependabot[bot] 127abf49f1 Bump cryptography from 43.0.1 to 44.0.1 (#3399) 2025-08-28 21:20:15 +02:00
dgtlmoon db81c3c5e2 Cryptography library - pinning version 2025-08-28 20:41:59 +02:00
dgtlmoon 9952af7a52 UI - Improving "real-time updates offline" message 2025-08-28 20:35:20 +02:00
dgtlmoon 790577c1b6 Build - Adding new cryptography library, solving apprise plugin issues (#3398) #3397 2025-08-28 20:29:21 +02:00
dgtlmoon bab362fb7d Update api-spec.yaml
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-08-28 14:38:19 +02:00
dgtlmoon a177d02406 API - API endpoint call validation against OpenAPI specification YML also (#3386) 2025-08-28 14:36:28 +02:00
dgtlmoon 8b8f280565 API Docs - Improve descriptions
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-08-25 13:28:04 +02:00
dgtlmoon e752875504 API Doc rebuild
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-08-24 16:47:07 +02:00
dgtlmoon 0a4562fc09 Bump API Docs slightly 2025-08-24 16:46:20 +02:00
dgtlmoon c84ac2eab1 Update settings.html text
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-08-24 00:55:44 +02:00
dgtlmoon 3ae07ac633 API - Use OpenAPI docs (#3384) 2025-08-24 00:48:17 +02:00
94 changed files with 1534 additions and 400 deletions
-1
View File
@@ -33,7 +33,6 @@ venv/
# Test and development files # Test and development files
test-datastore/ test-datastore/
tests/ tests/
docs/
*.md *.md
!README.md !README.md
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: 3.11 python-version: 3.11
+2 -2
View File
@@ -9,7 +9,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.11" python-version: "3.11"
- name: Install pypa/build - name: Install pypa/build
@@ -39,7 +39,7 @@ jobs:
name: python-package-distributions name: python-package-distributions
path: dist/ path: dist/
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: '3.11' python-version: '3.11'
- name: Test that the basic pip built package runs without error - name: Test that the basic pip built package runs without error
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: 3.11 python-version: 3.11
+4
View File
@@ -15,6 +15,10 @@ jobs:
ruff check . --select E9,F63,F7,F82 ruff check . --select E9,F63,F7,F82
# Complete check with errors treated as warnings # Complete check with errors treated as warnings
ruff check . --exit-zero ruff check . --exit-zero
- name: Validate OpenAPI spec
run: |
pip install openapi-spec-validator
python3 -c "from openapi_spec_validator import validate_spec; import yaml; validate_spec(yaml.safe_load(open('docs/api-spec.yaml')))"
test-application-3-10: test-application-3-10:
needs: lint-code needs: lint-code
@@ -24,7 +24,7 @@ jobs:
# Mainly just for link/flake8 # Mainly just for link/flake8
- name: Set up Python ${{ env.PYTHON_VERSION }} - name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
+14 -1
View File
@@ -5,7 +5,6 @@ ARG PYTHON_VERSION=3.11
FROM python:${PYTHON_VERSION}-slim-bookworm AS builder FROM python:${PYTHON_VERSION}-slim-bookworm AS builder
# See `cryptography` pin comment in requirements.txt # See `cryptography` pin comment in requirements.txt
ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \ g++ \
@@ -17,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxslt-dev \ libxslt-dev \
make \ make \
patch \ patch \
pkg-config \
zlib1g-dev zlib1g-dev
RUN mkdir /install RUN mkdir /install
@@ -26,6 +26,14 @@ COPY requirements.txt /requirements.txt
# Use cache mounts and multiple wheel sources for faster ARM builds # Use cache mounts and multiple wheel sources for faster ARM builds
ENV PIP_CACHE_DIR=/tmp/pip-cache ENV PIP_CACHE_DIR=/tmp/pip-cache
# Help Rust find OpenSSL for cryptography package compilation on ARM
ENV PKG_CONFIG_PATH="/usr/lib/pkgconfig:/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/lib/aarch64-linux-gnu/pkgconfig"
ENV PKG_CONFIG_ALLOW_SYSTEM_CFLAGS=1
ENV OPENSSL_DIR="/usr"
ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabihf"
ENV OPENSSL_INCLUDE_DIR="/usr/include/openssl"
# Additional environment variables for cryptography Rust build
ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1
RUN --mount=type=cache,target=/tmp/pip-cache \ RUN --mount=type=cache,target=/tmp/pip-cache \
pip install \ pip install \
--extra-index-url https://www.piwheels.org/simple \ --extra-index-url https://www.piwheels.org/simple \
@@ -76,6 +84,11 @@ EXPOSE 5000
# The actual flask app module # The actual flask app module
COPY changedetectionio /app/changedetectionio COPY changedetectionio /app/changedetectionio
# Also for OpenAPI validation wrapper - needs the YML
RUN [ ! -d "/app/docs" ] && mkdir /app/docs
COPY docs/api-spec.yaml /app/docs/api-spec.yaml
# Starting wrapper # Starting wrapper
COPY changedetection.py /app/changedetection.py COPY changedetection.py /app/changedetection.py
+2 -1
View File
@@ -1,7 +1,7 @@
recursive-include changedetectionio/api * recursive-include changedetectionio/api *
recursive-include changedetectionio/blueprint * recursive-include changedetectionio/blueprint *
recursive-include changedetectionio/content_fetchers *
recursive-include changedetectionio/conditions * recursive-include changedetectionio/conditions *
recursive-include changedetectionio/content_fetchers *
recursive-include changedetectionio/model * recursive-include changedetectionio/model *
recursive-include changedetectionio/notification * recursive-include changedetectionio/notification *
recursive-include changedetectionio/processors * recursive-include changedetectionio/processors *
@@ -9,6 +9,7 @@ recursive-include changedetectionio/realtime *
recursive-include changedetectionio/static * recursive-include changedetectionio/static *
recursive-include changedetectionio/templates * recursive-include changedetectionio/templates *
recursive-include changedetectionio/tests * recursive-include changedetectionio/tests *
recursive-include changedetectionio/widgets *
prune changedetectionio/static/package-lock.json prune changedetectionio/static/package-lock.json
prune changedetectionio/static/styles/node_modules prune changedetectionio/static/styles/node_modules
prune changedetectionio/static/styles/package-lock.json prune changedetectionio/static/styles/package-lock.json
+1 -1
View File
@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki # Read more https://github.com/dgtlmoon/changedetection.io/wiki
__version__ = '0.50.10' __version__ = '0.50.13'
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
+2 -1
View File
@@ -3,7 +3,7 @@ from changedetectionio.strtobool import strtobool
from flask_restful import abort, Resource from flask_restful import abort, Resource
from flask import request from flask import request
import validators import validators
from . import auth from . import auth, validate_openapi_request
class Import(Resource): class Import(Resource):
@@ -12,6 +12,7 @@ class Import(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('importWatches')
def post(self): def post(self):
"""Import a list of watched URLs.""" """Import a list of watched URLs."""
+6 -4
View File
@@ -1,9 +1,7 @@
from flask_expects_json import expects_json from flask_expects_json import expects_json
from flask_restful import Resource from flask_restful import Resource, abort
from . import auth
from flask_restful import abort, Resource
from flask import request from flask import request
from . import auth from . import auth, validate_openapi_request
from . import schema_create_notification_urls, schema_delete_notification_urls from . import schema_create_notification_urls, schema_delete_notification_urls
class Notifications(Resource): class Notifications(Resource):
@@ -12,6 +10,7 @@ class Notifications(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('getNotifications')
def get(self): def get(self):
"""Return Notification URL List.""" """Return Notification URL List."""
@@ -22,6 +21,7 @@ class Notifications(Resource):
}, 200 }, 200
@auth.check_token @auth.check_token
@validate_openapi_request('addNotifications')
@expects_json(schema_create_notification_urls) @expects_json(schema_create_notification_urls)
def post(self): def post(self):
"""Create Notification URLs.""" """Create Notification URLs."""
@@ -49,6 +49,7 @@ class Notifications(Resource):
return {'notification_urls': added_urls}, 201 return {'notification_urls': added_urls}, 201
@auth.check_token @auth.check_token
@validate_openapi_request('replaceNotifications')
@expects_json(schema_create_notification_urls) @expects_json(schema_create_notification_urls)
def put(self): def put(self):
"""Replace Notification URLs.""" """Replace Notification URLs."""
@@ -71,6 +72,7 @@ class Notifications(Resource):
return {'notification_urls': clean_urls}, 200 return {'notification_urls': clean_urls}, 200
@auth.check_token @auth.check_token
@validate_openapi_request('deleteNotifications')
@expects_json(schema_delete_notification_urls) @expects_json(schema_delete_notification_urls)
def delete(self): def delete(self):
"""Delete Notification URLs.""" """Delete Notification URLs."""
+2 -1
View File
@@ -1,6 +1,6 @@
from flask_restful import Resource, abort from flask_restful import Resource, abort
from flask import request from flask import request
from . import auth from . import auth, validate_openapi_request
class Search(Resource): class Search(Resource):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -8,6 +8,7 @@ class Search(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('searchWatches')
def get(self): def get(self):
"""Search for watches by URL or title text.""" """Search for watches by URL or title text."""
query = request.args.get('q', '').strip() query = request.args.get('q', '').strip()
+2 -1
View File
@@ -1,5 +1,5 @@
from flask_restful import Resource from flask_restful import Resource
from . import auth from . import auth, validate_openapi_request
class SystemInfo(Resource): class SystemInfo(Resource):
@@ -9,6 +9,7 @@ class SystemInfo(Resource):
self.update_q = kwargs['update_q'] self.update_q = kwargs['update_q']
@auth.check_token @auth.check_token
@validate_openapi_request('getSystemInfo')
def get(self): def get(self):
"""Return system info.""" """Return system info."""
import time import time
+6 -1
View File
@@ -7,7 +7,7 @@ from flask import request
from . import auth from . import auth
# Import schemas from __init__.py # Import schemas from __init__.py
from . import schema_tag, schema_create_tag, schema_update_tag from . import schema_tag, schema_create_tag, schema_update_tag, validate_openapi_request
class Tag(Resource): class Tag(Resource):
@@ -19,6 +19,7 @@ class Tag(Resource):
# Get information about a single tag # Get information about a single tag
# curl http://localhost:5000/api/v1/tag/<string:uuid> # curl http://localhost:5000/api/v1/tag/<string:uuid>
@auth.check_token @auth.check_token
@validate_openapi_request('getTag')
def get(self, uuid): def get(self, uuid):
"""Get data for a single tag/group, toggle notification muting, or recheck all.""" """Get data for a single tag/group, toggle notification muting, or recheck all."""
from copy import deepcopy from copy import deepcopy
@@ -50,6 +51,7 @@ class Tag(Resource):
return tag return tag
@auth.check_token @auth.check_token
@validate_openapi_request('deleteTag')
def delete(self, uuid): def delete(self, uuid):
"""Delete a tag/group and remove it from all watches.""" """Delete a tag/group and remove it from all watches."""
if not self.datastore.data['settings']['application']['tags'].get(uuid): if not self.datastore.data['settings']['application']['tags'].get(uuid):
@@ -66,6 +68,7 @@ class Tag(Resource):
return 'OK', 204 return 'OK', 204
@auth.check_token @auth.check_token
@validate_openapi_request('updateTag')
@expects_json(schema_update_tag) @expects_json(schema_update_tag)
def put(self, uuid): def put(self, uuid):
"""Update tag information.""" """Update tag information."""
@@ -80,6 +83,7 @@ class Tag(Resource):
@auth.check_token @auth.check_token
@validate_openapi_request('createTag')
# Only cares for {'title': 'xxxx'} # Only cares for {'title': 'xxxx'}
def post(self): def post(self):
"""Create a single tag/group.""" """Create a single tag/group."""
@@ -100,6 +104,7 @@ class Tags(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('listTags')
def get(self): def get(self):
"""List tags/groups.""" """List tags/groups."""
result = {} result = {}
+52 -1
View File
@@ -11,7 +11,40 @@ from . import auth
import copy import copy
# Import schemas from __init__.py # Import schemas from __init__.py
from . import schema, schema_create_watch, schema_update_watch from . import schema, schema_create_watch, schema_update_watch, validate_openapi_request
def validate_time_between_check_required(json_data):
"""
Validate that at least one time interval is specified when not using default settings.
Returns None if valid, or error message string if invalid.
Defaults to using global settings if time_between_check_use_default is not provided.
"""
# Default to using global settings if not specified
use_default = json_data.get('time_between_check_use_default', True)
# If using default settings, no validation needed
if use_default:
return None
# If not using defaults, check if time_between_check exists and has at least one non-zero value
time_check = json_data.get('time_between_check')
if not time_check:
# No time_between_check provided and not using defaults - this is an error
return "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
# time_between_check exists, check if it has at least one non-zero value
if any([
(time_check.get('weeks') or 0) > 0,
(time_check.get('days') or 0) > 0,
(time_check.get('hours') or 0) > 0,
(time_check.get('minutes') or 0) > 0,
(time_check.get('seconds') or 0) > 0
]):
return None
# time_between_check exists but all values are 0 or empty - this is an error
return "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
class Watch(Resource): class Watch(Resource):
@@ -25,6 +58,7 @@ class Watch(Resource):
# @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not "OK" # @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not "OK"
# ?recheck=true # ?recheck=true
@auth.check_token @auth.check_token
@validate_openapi_request('getWatch')
def get(self, uuid): def get(self, uuid):
"""Get information about a single watch, recheck, pause, or mute.""" """Get information about a single watch, recheck, pause, or mute."""
from copy import deepcopy from copy import deepcopy
@@ -57,6 +91,7 @@ class Watch(Resource):
return watch return watch
@auth.check_token @auth.check_token
@validate_openapi_request('deleteWatch')
def delete(self, uuid): def delete(self, uuid):
"""Delete a watch and related history.""" """Delete a watch and related history."""
if not self.datastore.data['watching'].get(uuid): if not self.datastore.data['watching'].get(uuid):
@@ -66,6 +101,7 @@ class Watch(Resource):
return 'OK', 204 return 'OK', 204
@auth.check_token @auth.check_token
@validate_openapi_request('updateWatch')
@expects_json(schema_update_watch) @expects_json(schema_update_watch)
def put(self, uuid): def put(self, uuid):
"""Update watch information.""" """Update watch information."""
@@ -78,6 +114,11 @@ class Watch(Resource):
if not request.json.get('proxy') in plist: if not request.json.get('proxy') in plist:
return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400
# Validate time_between_check when not using defaults
validation_error = validate_time_between_check_required(request.json)
if validation_error:
return validation_error, 400
watch.update(request.json) watch.update(request.json)
return "OK", 200 return "OK", 200
@@ -91,6 +132,7 @@ class WatchHistory(Resource):
# Get a list of available history for a watch by UUID # Get a list of available history for a watch by UUID
# curl http://localhost:5000/api/v1/watch/<string:uuid>/history # curl http://localhost:5000/api/v1/watch/<string:uuid>/history
@auth.check_token @auth.check_token
@validate_openapi_request('getWatchHistory')
def get(self, uuid): def get(self, uuid):
"""Get a list of all historical snapshots available for a watch.""" """Get a list of all historical snapshots available for a watch."""
watch = self.datastore.data['watching'].get(uuid) watch = self.datastore.data['watching'].get(uuid)
@@ -105,6 +147,7 @@ class WatchSingleHistory(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('getWatchSnapshot')
def get(self, uuid, timestamp): def get(self, uuid, timestamp):
"""Get single snapshot from watch.""" """Get single snapshot from watch."""
watch = self.datastore.data['watching'].get(uuid) watch = self.datastore.data['watching'].get(uuid)
@@ -138,6 +181,7 @@ class WatchFavicon(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('getWatchFavicon')
def get(self, uuid): def get(self, uuid):
"""Get favicon for a watch.""" """Get favicon for a watch."""
watch = self.datastore.data['watching'].get(uuid) watch = self.datastore.data['watching'].get(uuid)
@@ -172,6 +216,7 @@ class CreateWatch(Resource):
self.update_q = kwargs['update_q'] self.update_q = kwargs['update_q']
@auth.check_token @auth.check_token
@validate_openapi_request('createWatch')
@expects_json(schema_create_watch) @expects_json(schema_create_watch)
def post(self): def post(self):
"""Create a single watch.""" """Create a single watch."""
@@ -189,6 +234,11 @@ class CreateWatch(Resource):
if not json_data.get('proxy') in plist: if not json_data.get('proxy') in plist:
return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400
# Validate time_between_check when not using defaults
validation_error = validate_time_between_check_required(json_data)
if validation_error:
return validation_error, 400
extras = copy.deepcopy(json_data) 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) # Because we renamed 'tag' to 'tags' but don't want to change the API (can do this in v2 of the API)
@@ -207,6 +257,7 @@ class CreateWatch(Resource):
return "Invalid or unsupported URL", 400 return "Invalid or unsupported URL", 400
@auth.check_token @auth.check_token
@validate_openapi_request('listWatches')
def get(self): def get(self):
"""List watches.""" """List watches."""
list = {} list = {}
+45
View File
@@ -1,4 +1,10 @@
import copy import copy
import yaml
import functools
from flask import request, abort
from loguru import logger
from openapi_core import OpenAPI
from openapi_core.contrib.flask import FlaskOpenAPIRequest
from . import api_schema from . import api_schema
from ..model import watch_base from ..model import watch_base
@@ -8,6 +14,7 @@ schema = api_schema.build_watch_json_schema(watch_base_config)
schema_create_watch = copy.deepcopy(schema) schema_create_watch = copy.deepcopy(schema)
schema_create_watch['required'] = ['url'] schema_create_watch['required'] = ['url']
del schema_create_watch['properties']['last_viewed']
schema_update_watch = copy.deepcopy(schema) schema_update_watch = copy.deepcopy(schema)
schema_update_watch['additionalProperties'] = False schema_update_watch['additionalProperties'] = False
@@ -25,9 +32,47 @@ schema_create_notification_urls['required'] = ['notification_urls']
schema_delete_notification_urls = copy.deepcopy(schema_notification_urls) schema_delete_notification_urls = copy.deepcopy(schema_notification_urls)
schema_delete_notification_urls['required'] = ['notification_urls'] schema_delete_notification_urls['required'] = ['notification_urls']
@functools.cache
def get_openapi_spec():
import os
spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml')
with open(spec_path, 'r') as f:
spec_dict = yaml.safe_load(f)
_openapi_spec = OpenAPI.from_dict(spec_dict)
return _openapi_spec
def validate_openapi_request(operation_id):
"""Decorator to validate incoming requests against OpenAPI spec."""
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
try:
# Skip OpenAPI validation for GET requests since they don't have request bodies
if request.method.upper() != 'GET':
spec = get_openapi_spec()
openapi_request = FlaskOpenAPIRequest(request)
result = spec.unmarshal_request(openapi_request)
if result.errors:
from werkzeug.exceptions import BadRequest
error_details = []
for error in result.errors:
error_details.append(str(error))
raise BadRequest(f"OpenAPI validation failed: {error_details}")
except BadRequest:
# Re-raise BadRequest exceptions (validation failures)
raise
except Exception as e:
# If OpenAPI spec loading fails, log but don't break existing functionality
logger.critical(f"OpenAPI validation warning for {operation_id}: {e}")
abort(500)
return f(*args, **kwargs)
return wrapper
return decorator
# Import all API resources # Import all API resources
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon
from .Tags import Tags, Tag from .Tags import Tags, Tag
from .Import import Import from .Import import Import
from .SystemInfo import SystemInfo from .SystemInfo import SystemInfo
from .Notifications import Notifications from .Notifications import Notifications
+13
View File
@@ -78,6 +78,13 @@ def build_watch_json_schema(d):
]: ]:
schema['properties'][v]['anyOf'].append({'type': 'string', "maxLength": 5000}) schema['properties'][v]['anyOf'].append({'type': 'string', "maxLength": 5000})
for v in ['last_viewed']:
schema['properties'][v] = {
"type": "integer",
"description": "Unix timestamp in seconds of the last time the watch was viewed.",
"minimum": 0
}
# None or Boolean # None or Boolean
schema['properties']['track_ldjson_price_data']['anyOf'].append({'type': 'boolean'}) schema['properties']['track_ldjson_price_data']['anyOf'].append({'type': 'boolean'})
@@ -112,6 +119,12 @@ def build_watch_json_schema(d):
schema['properties']['time_between_check'] = build_time_between_check_json_schema() schema['properties']['time_between_check'] = build_time_between_check_json_schema()
schema['properties']['time_between_check_use_default'] = {
"type": "boolean",
"default": True,
"description": "Whether to use global settings for time between checks - defaults to true if not set"
}
schema['properties']['browser_steps'] = { schema['properties']['browser_steps'] = {
"anyOf": [ "anyOf": [
{ {
+8 -9
View File
@@ -310,15 +310,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
continue continue
if process_changedetection_results: 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 <title> 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: try:
datastore.update_watch(uuid=uuid, update_obj=update_obj) datastore.update_watch(uuid=uuid, update_obj=update_obj)
@@ -357,6 +348,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
# Always record attempt count # Always record attempt count
count = watch.get('check_count', 0) + 1 count = watch.get('check_count', 0) + 1
# Always record page title (used in notifications, and can change even when the content is the same)
try:
page_title = html_tools.extract_title(data=update_handler.fetcher.content)
logger.debug(f"UUID: {uuid} Page <title> is '{page_title}'")
datastore.update_watch(uuid=uuid, update_obj={'page_title': page_title})
except Exception as e:
logger.warning(f"UUID: {uuid} Exception when extracting <title> - {str(e)}")
# Record server header # Record server header
try: try:
server_header = update_handler.fetcher.headers.get('server', '').strip().lower()[:255] server_header = update_handler.fetcher.headers.get('server', '').strip().lower()[:255]
+7 -4
View File
@@ -108,10 +108,13 @@ def construct_blueprint(datastore: ChangeDetectionStore):
fe.link(link=diff_link) fe.link(link=diff_link)
# @todo watch should be a getter - watch.get('title') (internally if URL else..) # Same logic as watch-overview.html
if datastore.data['settings']['application']['ui'].get('use_page_title_in_list') or watch.get('use_page_title_in_list'):
watch_label = watch.label
else:
watch_label = watch.get('url')
watch_title = watch.get('title') if watch.get('title') else watch.get('url') fe.title(title=watch_label)
fe.title(title=watch_title)
try: try:
html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]), html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]),
@@ -127,7 +130,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# @todo User could decide if <link> goes to the diff page, or to the watch link # @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" 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) content = jinja_render(template_str=rss_template, watch_title=watch_label, html_diff=html_diff, watch_url=watch.link)
# Out of range chars could also break feedgen # Out of range chars could also break feedgen
if scan_invalid_chars_in_rss(content): if scan_invalid_chars_in_rss(content):
@@ -1,7 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form %} {% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, render_ternary_field %}
{% from '_common_fields.html' import render_common_settings_form %} {% from '_common_fields.html' import render_common_settings_form %}
<script> <script>
const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="global-settings")}}"; const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="global-settings")}}";
@@ -75,18 +75,10 @@
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.rss_hide_muted_watches) }} {{ render_checkbox_field(form.application.form.rss_hide_muted_watches) }}
</div> </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"> <div class="pure-control-group">
{{ render_field(form.application.form.rss_content_format) }} {{ 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> <span class="pure-form-message-inline">Love RSS? Does your reader support HTML? Set it here</span>
</div> </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"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }} {{ 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> <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>
@@ -203,7 +195,7 @@ nav
<div class="tab-pane-inner" id="api"> <div class="tab-pane-inner" id="api">
<h4>API Access</h4> <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> <p>Drive your changedetection.io via API, More about <a href="https://changedetection.io/docs/api_v1/index.html">API access and examples here</a>.</p>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.application.form.api_access_token_enabled) }} {{ render_checkbox_field(form.application.form.api_access_token_enabled) }}
@@ -260,6 +252,13 @@ nav
{{ render_checkbox_field(form.application.form.ui.form.favicons_enabled, class="") }} {{ render_checkbox_field(form.application.form.ui.form.favicons_enabled, class="") }}
<span class="pure-form-message-inline">Enable or Disable Favicons next to the watch list</span> <span class="pure-form-message-inline">Enable or Disable Favicons next to the watch list</span>
</div> </div>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.ui.use_page_title_in_list) }}
</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> </div>
<div class="tab-pane-inner" id="proxies"> <div class="tab-pane-inner" id="proxies">
@@ -324,8 +323,8 @@ nav
<div id="actions"> <div id="actions">
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_button(form.save_button) }} {{ render_button(form.save_button) }}
<a href="{{url_for('watchlist.index')}}" class="pure-button button-small button-cancel">Back</a> <a href="{{url_for('watchlist.index')}}" class="pure-button button-cancel">Back</a>
<a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a> <a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-error">Clear Snapshot History</a>
</div> </div>
</div> </div>
</form> </form>
@@ -1,6 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %} {% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_ternary_field %}
{% from '_common_fields.html' import render_common_settings_form %} {% from '_common_fields.html' import render_common_settings_form %}
<script> <script>
const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="group-settings")}}"; const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="group-settings")}}";
@@ -64,7 +64,7 @@
<div class="tab-pane-inner" id="notifications"> <div class="tab-pane-inner" id="notifications">
<fieldset> <fieldset>
<div class="pure-control-group inline-radio"> <div class="pure-control-group inline-radio">
{{ render_checkbox_field(form.notification_muted) }} {{ render_ternary_field(form.notification_muted, BooleanField=True) }}
</div> </div>
{% if 1 %} {% if 1 %}
<div class="pure-control-group inline-radio"> <div class="pure-control-group inline-radio">
+1
View File
@@ -242,6 +242,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
'available_timezones': sorted(available_timezones()), 'available_timezones': sorted(available_timezones()),
'browser_steps_config': browser_step_ui_config, 'browser_steps_config': browser_step_ui_config,
'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), 'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
'extra_classes': 'checking-now' if worker_handler.is_watch_running(uuid) else '',
'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(), 'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
'extra_processor_config': form.extra_tab_content(), 'extra_processor_config': form.extra_tab_content(),
'extra_title': f" - Edit - {watch.label}", 'extra_title': f" - Edit - {watch.label}",
@@ -1,6 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% 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 '_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, render_ternary_field %}
{% from '_common_fields.html' import render_common_settings_form %} {% from '_common_fields.html' import render_common_settings_form %}
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> <script src="{{url_for('static_content', group='js', filename='tabs.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='vis.js')}}" defer></script>
@@ -72,15 +72,16 @@
<div class="pure-form-message">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></div> <div class="pure-form-message">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></div>
<div class="pure-form-message">Variables are supported in the URL (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a>).</div> <div class="pure-form-message">Variables are supported in the URL (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a>).</div>
</div> </div>
<div class="pure-control-group">
{{ render_field(form.tags) }}
<span class="pure-form-message-inline">Organisational tag/group name used in the main listing page</span>
</div>
<div class="pure-control-group inline-radio"> <div class="pure-control-group inline-radio">
{{ render_field(form.processor) }} {{ render_field(form.processor) }}
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_field(form.title, class="m-d") }} {{ render_field(form.title, class="m-d", placeholder=watch.label) }}
</div> <span class="pure-form-message-inline">Automatically uses the page title if found, you can also use your own title/description here</span>
<div class="pure-control-group">
{{ render_field(form.tags) }}
<span class="pure-form-message-inline">Organisational tag/group name used in the main listing page</span>
</div> </div>
<div class="pure-control-group time-between-check border-fieldset"> <div class="pure-control-group time-between-check border-fieldset">
@@ -101,15 +102,16 @@
</div> </div>
<br> <br>
</div> </div>
<div class="pure-control-group">
{{ render_checkbox_field(form.extract_title_as_title) }}
</div>
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_checkbox_field(form.filter_failure_notification_send) }} {{ render_checkbox_field(form.filter_failure_notification_send) }}
<span class="pure-form-message-inline"> <span class="pure-form-message-inline">
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. 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.
</span> </span>
</div> </div>
<div class="pure-control-group">
{{ render_ternary_field(form.use_page_title_in_list) }}
</div>
</fieldset> </fieldset>
</div> </div>
@@ -262,7 +264,7 @@ Math: {{ 1 + 1 }}") }}
<div class="tab-pane-inner" id="notifications"> <div class="tab-pane-inner" id="notifications">
<fieldset> <fieldset>
<div class="pure-control-group inline-radio"> <div class="pure-control-group inline-radio">
{{ render_checkbox_field(form.notification_muted) }} {{ render_ternary_field(form.notification_muted, BooleanField=true) }}
</div> </div>
{% if watch_needs_selenium_or_playwright %} {% if watch_needs_selenium_or_playwright %}
<div class="pure-control-group inline-radio"> <div class="pure-control-group inline-radio">
@@ -469,11 +471,11 @@ Math: {{ 1 + 1 }}") }}
<div class="pure-control-group"> <div class="pure-control-group">
{{ render_button(form.save_button) }} {{ render_button(form.save_button) }}
<a href="{{url_for('ui.form_delete', uuid=uuid)}}" <a href="{{url_for('ui.form_delete', uuid=uuid)}}"
class="pure-button button-small button-error ">Delete</a> class="pure-button button-error ">Delete</a>
{% if watch.history_n %}<a href="{{url_for('ui.clear_watch_history', uuid=uuid)}}" {% if watch.history_n %}<a href="{{url_for('ui.clear_watch_history', uuid=uuid)}}"
class="pure-button button-small button-error ">Clear History</a>{% endif %} class="pure-button button-error">Clear History</a>{% endif %}
<a href="{{url_for('ui.form_clone', uuid=uuid)}}" <a href="{{url_for('ui.form_clone', uuid=uuid)}}"
class="pure-button button-small ">Clone &amp; Edit</a> class="pure-button">Clone &amp; Edit</a>
</div> </div>
</div> </div>
</form> </form>
@@ -44,12 +44,16 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# Sort by last_changed and add the uuid which is usually the key.. # Sort by last_changed and add the uuid which is usually the key..
sorted_watches = [] sorted_watches = []
with_errors = request.args.get('with_errors') == "1" with_errors = request.args.get('with_errors') == "1"
unread_only = request.args.get('unread') == "1"
errored_count = 0 errored_count = 0
search_q = request.args.get('q').strip().lower() if request.args.get('q') else False search_q = request.args.get('q').strip().lower() if request.args.get('q') else False
for uuid, watch in datastore.data['watching'].items(): for uuid, watch in datastore.data['watching'].items():
if with_errors and not watch.get('last_error'): if with_errors and not watch.get('last_error'):
continue continue
if unread_only and (watch.viewed or watch.last_changed == 0) :
continue
if active_tag_uuid and not active_tag_uuid in watch['tags']: if active_tag_uuid and not active_tag_uuid in watch['tags']:
continue continue
if watch.get('last_error'): if watch.get('last_error'):
@@ -118,7 +118,8 @@ document.addEventListener('DOMContentLoaded', function() {
{%- set checking_now = is_checking_now(watch) -%} {%- set checking_now = is_checking_now(watch) -%}
{%- set history_n = watch.history_n -%} {%- set history_n = watch.history_n -%}
{%- set favicon = watch.get_favicon_filename() -%} {%- set favicon = watch.get_favicon_filename() -%}
{# Mirror in changedetectionio/static/js/realtime.js for the frontend #} {%- set system_use_url_watchlist = datastore.data['settings']['application']['ui'].get('use_page_title_in_list') -%}
{# Class settings mirrored in changedetectionio/static/js/realtime.js for the frontend #}
{%- set row_classes = [ {%- set row_classes = [
loop.cycle('pure-table-odd', 'pure-table-even'), loop.cycle('pure-table-odd', 'pure-table-even'),
'processor-' ~ watch['processor'], 'processor-' ~ watch['processor'],
@@ -133,7 +134,8 @@ document.addEventListener('DOMContentLoaded', function() {
'checking-now' if checking_now else '', 'checking-now' if checking_now else '',
'notification_muted' if watch.notification_muted else '', 'notification_muted' if watch.notification_muted else '',
'single-history' if history_n == 1 else '', 'single-history' if history_n == 1 else '',
'multiple-history' if history_n >= 2 else '', 'multiple-history' if history_n >= 2 else '',
'use-html-title' if system_use_url_watchlist else 'no-html-title',
] -%} ] -%}
<tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}" class="{{ row_classes | reject('equalto', '') | join(' ') }}"> <tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}" class="{{ row_classes | reject('equalto', '') | join(' ') }}">
<td class="inline checkbox-uuid" ><div><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span class="counter-i">{{ loop.index+pagination.skip }}</span></div></td> <td class="inline checkbox-uuid" ><div><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span class="counter-i">{{ loop.index+pagination.skip }}</span></div></td>
@@ -155,7 +157,12 @@ document.addEventListener('DOMContentLoaded', function() {
{% endif %} {% endif %}
<div> <div>
<span class="watch-title"> <span class="watch-title">
{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}&nbsp;<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}">&nbsp;</a> {% if system_use_url_watchlist or watch.get('use_page_title_in_list') %}
{{watch.label}}
{% else %}
{{watch.url}}
{% endif %}
<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}">&nbsp;</a>
</span> </span>
<div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list) }}</div> <div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list) }}</div>
{%- if watch['processor'] == 'text_json_diff' -%} {%- if watch['processor'] == 'text_json_diff' -%}
@@ -245,6 +252,9 @@ document.addEventListener('DOMContentLoaded', function() {
<a href="{{url_for('ui.mark_all_viewed', tag=active_tag_uuid) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed in '{{active_tag.title}}'</a> <a href="{{url_for('ui.mark_all_viewed', tag=active_tag_uuid) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed in '{{active_tag.title}}'</a>
</li> </li>
{%- endif -%} {%- endif -%}
<li id="post-list-unread" class="{%- if has_unviewed -%}has-unviewed{%- endif -%}" style="display: none;" >
<a href="{{url_for('watchlist.index', unread=1, tag=request.args.get('tag')) }}" class="pure-button button-tag">Unread</a>
</li>
<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 <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> all {% if active_tag_uuid %} in '{{active_tag.title}}'{%endif%}</a>
@@ -47,6 +47,7 @@ async () => {
'nicht lieferbar', 'nicht lieferbar',
'nicht verfügbar', 'nicht verfügbar',
'nicht vorrätig', 'nicht vorrätig',
'nicht mehr lieferbar',
'nicht zur verfügung', 'nicht zur verfügung',
'nie znaleziono produktów', 'nie znaleziono produktów',
'niet beschikbaar', 'niet beschikbaar',
+165 -11
View File
@@ -23,11 +23,14 @@ from wtforms import (
) )
from flask_wtf.file import FileField, FileAllowed from flask_wtf.file import FileField, FileAllowed
from wtforms.fields import FieldList from wtforms.fields import FieldList
from wtforms.utils import unset_value
from wtforms.validators import ValidationError from wtforms.validators import ValidationError
from validators.url import url as url_validator from validators.url import url as url_validator
from changedetectionio.widgets import TernaryNoneBooleanField
# default # default
# each select <option data-enabled="enabled-0-0" # each select <option data-enabled="enabled-0-0"
@@ -54,6 +57,8 @@ valid_method = {
default_method = 'GET' default_method = 'GET'
allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
REQUIRE_ATLEAST_ONE_TIME_PART_MESSAGE_DEFAULT='At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.'
REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT='At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.'
class StringListField(StringField): class StringListField(StringField):
widget = widgets.TextArea() widget = widgets.TextArea()
@@ -210,6 +215,33 @@ class ScheduleLimitForm(Form):
self.sunday.form.enabled.label.text = "Sunday" self.sunday.form.enabled.label.text = "Sunday"
def validate_time_between_check_has_values(form):
"""
Custom validation function for TimeBetweenCheckForm.
Returns True if at least one time interval field has a value > 0.
"""
return any([
form.weeks.data and form.weeks.data > 0,
form.days.data and form.days.data > 0,
form.hours.data and form.hours.data > 0,
form.minutes.data and form.minutes.data > 0,
form.seconds.data and form.seconds.data > 0
])
class RequiredTimeInterval(object):
"""
WTForms validator that ensures at least one time interval field has a value > 0.
Use this with FormField(TimeBetweenCheckForm, validators=[RequiredTimeInterval()]).
"""
def __init__(self, message=None):
self.message = message or 'At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.'
def __call__(self, form, field):
if not validate_time_between_check_has_values(field.form):
raise ValidationError(self.message)
class TimeBetweenCheckForm(Form): class TimeBetweenCheckForm(Form):
weeks = IntegerField('Weeks', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) 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")]) days = IntegerField('Days', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
@@ -218,6 +250,123 @@ class TimeBetweenCheckForm(Form):
seconds = IntegerField('Seconds', 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 # @todo add total seconds minimum validatior = minimum_seconds_recheck_time
def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs):
super().__init__(formdata, obj, prefix, data, meta, **kwargs)
self.require_at_least_one = kwargs.get('require_at_least_one', False)
self.require_at_least_one_message = kwargs.get('require_at_least_one_message', REQUIRE_ATLEAST_ONE_TIME_PART_MESSAGE_DEFAULT)
def validate(self, **kwargs):
"""Custom validation that can optionally require at least one time interval."""
# Run normal field validation first
if not super().validate(**kwargs):
return False
# Apply optional "at least one" validation
if self.require_at_least_one:
if not validate_time_between_check_has_values(self):
# Add error to the form's general errors (not field-specific)
if not hasattr(self, '_formdata_errors'):
self._formdata_errors = []
self._formdata_errors.append(self.require_at_least_one_message)
return False
return True
class EnhancedFormField(FormField):
"""
An enhanced FormField that supports conditional validation with top-level error messages.
Adds a 'top_errors' property for validation errors at the FormField level.
"""
def __init__(self, form_class, label=None, validators=None, separator="-",
conditional_field=None, conditional_message=None, conditional_test_function=None, **kwargs):
"""
Initialize EnhancedFormField with optional conditional validation.
:param conditional_field: Name of the field this FormField depends on (e.g. 'time_between_check_use_default')
:param conditional_message: Error message to show when validation fails
:param conditional_test_function: Custom function to test if FormField has valid values.
Should take self.form as parameter and return True if valid.
"""
super().__init__(form_class, label, validators, separator, **kwargs)
self.top_errors = []
self.conditional_field = conditional_field
self.conditional_message = conditional_message or "At least one field must have a value when not using defaults."
self.conditional_test_function = conditional_test_function
def validate(self, form, extra_validators=()):
"""
Custom validation that supports conditional logic and stores top-level errors.
"""
self.top_errors = []
# First run the normal FormField validation
base_valid = super().validate(form, extra_validators)
# Apply conditional validation if configured
if self.conditional_field and hasattr(form, self.conditional_field):
conditional_field_obj = getattr(form, self.conditional_field)
# If the conditional field is False/unchecked, check if this FormField has any values
if not conditional_field_obj.data:
# Use custom test function if provided, otherwise use generic fallback
if self.conditional_test_function:
has_any_value = self.conditional_test_function(self.form)
else:
# Generic fallback - check if any field has truthy data
has_any_value = any(field.data for field in self.form if hasattr(field, 'data') and field.data)
if not has_any_value:
self.top_errors.append(self.conditional_message)
base_valid = False
return base_valid
class RequiredFormField(FormField):
"""
A FormField that passes require_at_least_one=True to TimeBetweenCheckForm.
Use this when you want the sub-form to always require at least one value.
"""
def __init__(self, form_class, label=None, validators=None, separator="-", **kwargs):
super().__init__(form_class, label, validators, separator, **kwargs)
def process(self, formdata, data=unset_value, extra_filters=None):
if extra_filters:
raise TypeError(
"FormField cannot take filters, as the encapsulated"
"data is not mutable."
)
if data is unset_value:
try:
data = self.default()
except TypeError:
data = self.default
self._obj = data
self.object_data = data
prefix = self.name + self.separator
# Pass require_at_least_one=True to the sub-form
if isinstance(data, dict):
self.form = self.form_class(formdata=formdata, prefix=prefix, require_at_least_one=True, **data)
else:
self.form = self.form_class(formdata=formdata, obj=data, prefix=prefix, require_at_least_one=True)
@property
def errors(self):
"""Include sub-form validation errors"""
form_errors = self.form.errors
# Add any general form errors to a special 'form' key
if hasattr(self.form, '_formdata_errors') and self.form._formdata_errors:
form_errors = dict(form_errors) # Make a copy
form_errors['form'] = self.form._formdata_errors
return form_errors
# Separated by key:value # Separated by key:value
class StringDictKeyValue(StringField): class StringDictKeyValue(StringField):
widget = widgets.TextArea() widget = widgets.TextArea()
@@ -346,7 +495,7 @@ class ValidateJinja2Template(object):
joined_data = ' '.join(map(str, field.data)) if isinstance(field.data, list) else f"{field.data}" joined_data = ' '.join(map(str, field.data)) if isinstance(field.data, list) else f"{field.data}"
try: try:
jinja2_env = ImmutableSandboxedEnvironment(loader=BaseLoader) jinja2_env = ImmutableSandboxedEnvironment(loader=BaseLoader, extensions=['jinja2_time.TimeExtension'])
jinja2_env.globals.update(notification.valid_tokens) jinja2_env.globals.update(notification.valid_tokens)
# Extra validation tokens provided on the form_class(... extra_tokens={}) setup # Extra validation tokens provided on the form_class(... extra_tokens={}) setup
if hasattr(field, 'extra_notification_tokens'): if hasattr(field, 'extra_notification_tokens'):
@@ -548,7 +697,6 @@ class commonSettingsForm(Form):
self.notification_title.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', {}) 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()]) 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_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_format = SelectField('Notification format', choices=valid_notification_formats.keys())
@@ -582,11 +730,16 @@ class processor_text_json_diff_form(commonSettingsForm):
url = fields.URLField('URL', validators=[validateURL()]) url = fields.URLField('URL', validators=[validateURL()])
tags = StringTagUUID('Group tag', [validators.Optional()], default='') tags = StringTagUUID('Group tag', [validators.Optional()], default='')
time_between_check = FormField(TimeBetweenCheckForm) time_between_check = EnhancedFormField(
TimeBetweenCheckForm,
conditional_field='time_between_check_use_default',
conditional_message=REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT,
conditional_test_function=validate_time_between_check_has_values
)
time_schedule_limit = FormField(ScheduleLimitForm) time_schedule_limit = FormField(ScheduleLimitForm)
time_between_check_use_default = BooleanField('Use global settings for time between check', default=False) time_between_check_use_default = BooleanField('Use global settings for time between check and scheduler.', default=False)
include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='') include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='')
@@ -616,18 +769,18 @@ class processor_text_json_diff_form(commonSettingsForm):
text_should_not_be_present = StringListField('Block change-detection while text matches', [validators.Optional(), ValidateListRegex()]) 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()]) 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"}) save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})
proxy = RadioField('Proxy') proxy = RadioField('Proxy')
# filter_failure_notification_send @todo make ternary
filter_failure_notification_send = BooleanField( filter_failure_notification_send = BooleanField(
'Send a notification when the filter can no longer be found on the page', default=False) 'Send a notification when the filter can no longer be found on the page', default=False)
notification_muted = TernaryNoneBooleanField('Notifications', default=None, yes_text="Muted", no_text="On")
notification_muted = BooleanField('Notifications Muted / Off', default=False)
notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', 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_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 conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here
use_page_title_in_list = TernaryNoneBooleanField('Use page <title> in list', default=None)
def extra_tab_content(self): def extra_tab_content(self):
return None return None
@@ -727,7 +880,7 @@ class DefaultUAInputForm(Form):
# datastore.data['settings']['requests'].. # datastore.data['settings']['requests']..
class globalSettingsRequestForm(Form): class globalSettingsRequestForm(Form):
time_between_check = FormField(TimeBetweenCheckForm) time_between_check = RequiredFormField(TimeBetweenCheckForm)
time_schedule_limit = FormField(ScheduleLimitForm) time_schedule_limit = FormField(ScheduleLimitForm)
proxy = RadioField('Proxy') proxy = RadioField('Proxy')
jitter_seconds = IntegerField('Random jitter seconds ± check', jitter_seconds = IntegerField('Random jitter seconds ± check',
@@ -755,6 +908,7 @@ class globalSettingsApplicationUIForm(Form):
open_diff_in_new_tab = BooleanField("Open 'History' page in a new tab", default=True, validators=[validators.Optional()]) open_diff_in_new_tab = BooleanField("Open 'History' page in a new tab", default=True, validators=[validators.Optional()])
socket_io_enabled = BooleanField('Realtime UI Updates Enabled', default=True, validators=[validators.Optional()]) socket_io_enabled = BooleanField('Realtime UI Updates Enabled', default=True, validators=[validators.Optional()])
favicons_enabled = BooleanField('Favicons Enabled', default=True, validators=[validators.Optional()]) favicons_enabled = BooleanField('Favicons Enabled', default=True, validators=[validators.Optional()])
use_page_title_in_list = BooleanField('Use page <title> in watch overview list') #BooleanField=True
# datastore.data['settings']['application'].. # datastore.data['settings']['application']..
class globalSettingsApplicationForm(commonSettingsForm): class globalSettingsApplicationForm(commonSettingsForm):
@@ -779,7 +933,7 @@ class globalSettingsApplicationForm(commonSettingsForm):
removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"}) removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"})
render_anchor_tag_content = BooleanField('Render anchor tag content', default=False) 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()]) shared_diff_access = BooleanField('Allow anonymous access to watch history page when password is enabled', default=False, validators=[validators.Optional()])
rss_hide_muted_watches = BooleanField('Hide muted watches from RSS feed', default=True, rss_hide_muted_watches = BooleanField('Hide muted watches from RSS feed', default=True,
validators=[validators.Optional()]) validators=[validators.Optional()])
filter_failure_notification_threshold_attempts = IntegerField('Number of times the filter can be missing before sending a notification', filter_failure_notification_threshold_attempts = IntegerField('Number of times the filter can be missing before sending a notification',
@@ -801,7 +955,7 @@ class globalSettingsForm(Form):
requests = FormField(globalSettingsRequestForm) requests = FormField(globalSettingsRequestForm)
application = FormField(globalSettingsApplicationForm) application = FormField(globalSettingsApplicationForm)
save_button = SubmitField('Save', render_kw={"class": "pure-button button-small pure-button-primary"}) save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})
class extractDataForm(Form): class extractDataForm(Form):
+46
View File
@@ -1,6 +1,7 @@
from loguru import logger from loguru import logger
from lxml import etree from lxml import etree
from typing import List from typing import List
import html
import json import json
import re import re
@@ -9,6 +10,11 @@ TEXT_FILTER_LIST_LINE_SUFFIX = "<br>"
TRANSLATE_WHITESPACE_TABLE = str.maketrans('', '', '\r\n\t ') TRANSLATE_WHITESPACE_TABLE = str.maketrans('', '', '\r\n\t ')
PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$' PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$'
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
META_CS = re.compile(r'<meta[^>]+charset=["\']?\s*([a-z0-9_\-:+.]+)', re.I)
META_CT = re.compile(r'<meta[^>]+http-equiv=["\']?content-type["\']?[^>]*content=["\'][^>]*charset=([a-z0-9_\-:+.]+)', re.I)
# 'price' , 'lowPrice', 'highPrice' are usually under here # '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 # 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"] LD_JSON_PRODUCT_OFFER_SELECTORS = ["json:$..offers", "json:$..Offers"]
@@ -510,3 +516,43 @@ def get_triggered_text(content, trigger_text):
i += 1 i += 1
return triggered_text return triggered_text
def extract_title(data: bytes | str, sniff_bytes: int = 2048, scan_chars: int = 8192) -> str | None:
try:
# Only decode/process the prefix we need for title extraction
match data:
case bytes() if data.startswith((b"\xff\xfe", b"\xfe\xff")):
prefix = data[:scan_chars * 2].decode("utf-16", errors="replace")
case bytes() if data.startswith((b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff")):
prefix = data[:scan_chars * 4].decode("utf-32", errors="replace")
case bytes():
try:
prefix = data[:scan_chars].decode("utf-8")
except UnicodeDecodeError:
try:
head = data[:sniff_bytes].decode("ascii", errors="ignore")
if m := (META_CS.search(head) or META_CT.search(head)):
enc = m.group(1).lower()
else:
enc = "cp1252"
prefix = data[:scan_chars * 2].decode(enc, errors="replace")
except Exception as e:
logger.error(f"Title extraction encoding detection failed: {e}")
return None
case str():
prefix = data[:scan_chars] if len(data) > scan_chars else data
case _:
logger.error(f"Title extraction received unsupported data type: {type(data)}")
return None
# Search only in the prefix
if m := TITLE_RE.search(prefix):
title = html.unescape(" ".join(m.group(1).split())).strip()
# Some safe limit
return title[:2000]
return None
except Exception as e:
logger.error(f"Title extraction failed: {e}")
return None
+3 -2
View File
@@ -39,12 +39,12 @@ class model(dict):
'api_access_token_enabled': True, 'api_access_token_enabled': True,
'base_url' : None, 'base_url' : None,
'empty_pages_are_a_change': False, 'empty_pages_are_a_change': False,
'extract_title_as_title': False,
'fetch_backend': getenv("DEFAULT_FETCH_BACKEND", "html_requests"), 'fetch_backend': getenv("DEFAULT_FETCH_BACKEND", "html_requests"),
'filter_failure_notification_threshold_attempts': _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT, 'filter_failure_notification_threshold_attempts': _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT,
'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum 'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum
'global_subtractive_selectors': [], 'global_subtractive_selectors': [],
'ignore_whitespace': True, 'ignore_whitespace': True,
'ignore_status_codes': False, #@todo implement, as ternary.
'notification_body': default_notification_body, 'notification_body': default_notification_body,
'notification_format': default_notification_format, 'notification_format': default_notification_format,
'notification_title': default_notification_title, 'notification_title': default_notification_title,
@@ -57,10 +57,11 @@ class model(dict):
'rss_hide_muted_watches': True, 'rss_hide_muted_watches': True,
'schema_version' : 0, 'schema_version' : 0,
'shared_diff_access': False, 'shared_diff_access': False,
'webdriver_delay': None , # Extra delay in seconds before extracting text
'tags': {}, #@todo use Tag.model initialisers 'tags': {}, #@todo use Tag.model initialisers
'timezone': None, # Default IANA timezone name 'timezone': None, # Default IANA timezone name
'webdriver_delay': None , # Extra delay in seconds before extracting text
'ui': { 'ui': {
'use_page_title_in_list': True,
'open_diff_in_new_tab': True, 'open_diff_in_new_tab': True,
'socket_io_enabled': True, 'socket_io_enabled': True,
'favicons_enabled': True 'favicons_enabled': True
+2 -2
View File
@@ -169,8 +169,8 @@ class model(watch_base):
@property @property
def label(self): def label(self):
# Used for sorting # Used for sorting, display, etc
return self.get('title') if self.get('title') else self.get('url') return self.get('title') or self.get('page_title') or self.get('url')
@property @property
def last_changed(self): def last_changed(self):
+4 -2
View File
@@ -24,7 +24,6 @@ class watch_base(dict):
'content-type': None, 'content-type': None,
'date_created': None, 'date_created': None,
'extract_text': [], # Extract text by regex after filters 'extract_text': [], # Extract text by regex after filters
'extract_title_as_title': False,
'fetch_backend': 'system', # plaintext, playwright etc 'fetch_backend': 'system', # plaintext, playwright etc
'fetch_time': 0.0, 'fetch_time': 0.0,
'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')), 'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),
@@ -35,6 +34,7 @@ class watch_base(dict):
'has_ldjson_price_data': None, 'has_ldjson_price_data': None,
'headers': {}, # Extra headers to send 'headers': {}, # Extra headers to send
'ignore_text': [], # List of text to ignore when calculating the comparison checksum 'ignore_text': [], # List of text to ignore when calculating the comparison checksum
'ignore_status_codes': None,
'in_stock_only': True, # Only trigger change on going to instock from out-of-stock 'in_stock_only': True, # Only trigger change on going to instock from out-of-stock
'include_filters': [], 'include_filters': [],
'last_checked': 0, 'last_checked': 0,
@@ -49,6 +49,7 @@ class watch_base(dict):
'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL 'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL
'notification_title': None, 'notification_title': None,
'notification_urls': [], # List of URLs to add to the notification Queue (Usually AppRise) 'notification_urls': [], # List of URLs to add to the notification Queue (Usually AppRise)
'page_title': None, # <title> from the page
'paused': False, 'paused': False,
'previous_md5': False, 'previous_md5': False,
'previous_md5_before_filters': False, # Used for skipping changedetection entirely 'previous_md5_before_filters': False, # Used for skipping changedetection entirely
@@ -122,12 +123,13 @@ class watch_base(dict):
} }
}, },
}, },
'title': None, 'title': None, # An arbitrary field that overrides 'page_title'
'track_ldjson_price_data': None, 'track_ldjson_price_data': None,
'trim_text_whitespace': False, 'trim_text_whitespace': False,
'remove_duplicate_lines': False, 'remove_duplicate_lines': False,
'trigger_text': [], # List of text or regex to wait for until a change is detected 'trigger_text': [], # List of text or regex to wait for until a change is detected
'url': '', 'url': '',
'use_page_title_in_list': None, # None = use system settings
'uuid': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
'webdriver_delay': None, 'webdriver_delay': None,
'webdriver_js_execute_code': None, # Run before change-detection 'webdriver_js_execute_code': None, # Run before change-detection
+1 -1
View File
@@ -149,7 +149,7 @@ def create_notification_parameters(n_object, datastore):
uuid = n_object['uuid'] if 'uuid' in n_object else '' uuid = n_object['uuid'] if 'uuid' in n_object else ''
if uuid: if uuid:
watch_title = datastore.data['watching'][uuid].get('title', '') watch_title = datastore.data['watching'][uuid].label
tag_list = [] tag_list = []
tags = datastore.get_all_tags_for_watch(uuid) tags = datastore.get_all_tags_for_watch(uuid)
if tags: if tags:
@@ -251,8 +251,7 @@ class perform_site_check(difference_detection_processor):
update_obj["last_check_status"] = self.fetcher.get_last_status_code() update_obj["last_check_status"] = self.fetcher.get_last_status_code()
# 615 Extract text by regex # 615 Extract text by regex
extract_text = watch.get('extract_text', []) extract_text = list(dict.fromkeys(watch.get('extract_text', []) + self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='extract_text')))
extract_text += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='extract_text')
if len(extract_text) > 0: if len(extract_text) > 0:
regex_matched_output = [] regex_matched_output = []
for s_re in extract_text: for s_re in extract_text:
@@ -311,8 +310,7 @@ class perform_site_check(difference_detection_processor):
############ Blocking rules, after checksum ################# ############ Blocking rules, after checksum #################
blocked = False blocked = False
trigger_text = watch.get('trigger_text', []) trigger_text = list(dict.fromkeys(watch.get('trigger_text', []) + self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='trigger_text')))
trigger_text += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='trigger_text')
if len(trigger_text): if len(trigger_text):
# Assume blocked # Assume blocked
blocked = True blocked = True
@@ -326,8 +324,7 @@ class perform_site_check(difference_detection_processor):
if result: if result:
blocked = False blocked = False
text_should_not_be_present = watch.get('text_should_not_be_present', []) text_should_not_be_present = list(dict.fromkeys(watch.get('text_should_not_be_present', []) + self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='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 len(text_should_not_be_present):
# If anything matched, then we should block a change from happening # If anything matched, then we should block a change from happening
result = html_tools.strip_ignore_text(content=str(stripped_text_from_html), result = html_tools.strip_ignore_text(content=str(stripped_text_from_html),
+1
View File
@@ -153,6 +153,7 @@ $(document).ready(function () {
// Tabs at bottom of list // Tabs at bottom of list
$('#post-list-mark-views').toggleClass("has-unviewed", general_stats.has_unviewed); $('#post-list-mark-views').toggleClass("has-unviewed", general_stats.has_unviewed);
$('#post-list-unread').toggleClass("has-unviewed", general_stats.has_unviewed);
$('#post-list-with-errors').toggleClass("has-error", general_stats.count_errors !== 0) $('#post-list-with-errors').toggleClass("has-error", general_stats.count_errors !== 0)
$('#post-list-with-errors a').text(`With errors (${ general_stats.count_errors })`); $('#post-list-with-errors a').text(`With errors (${ general_stats.count_errors })`);
@@ -51,6 +51,7 @@ $(document).ready(function () {
$('#notification_body').val(''); $('#notification_body').val('');
$('#notification_format').val('System default'); $('#notification_format').val('System default');
$('#notification_urls').val(''); $('#notification_urls').val('');
$('#notification_muted_none').prop('checked', true); // in the case of a ternary field
e.preventDefault(); e.preventDefault();
}); });
$("#notification-token-toggle").click(function (e) { $("#notification-token-toggle").click(function (e) {
@@ -24,6 +24,9 @@ body.checking-now {
#post-list-mark-views.has-unviewed { #post-list-mark-views.has-unviewed {
display: inline-block !important; display: inline-block !important;
} }
#post-list-unread.has-unviewed {
display: inline-block !important;
}
} }
@@ -0,0 +1,115 @@
// Ternary radio button group component
.ternary-radio-group {
display: flex;
gap: 0;
border: 1px solid var(--color-grey-750);
border-radius: 4px;
overflow: hidden;
width: fit-content;
background: var(--color-background);
.ternary-radio-option {
position: relative;
cursor: pointer;
margin: 0;
display: flex;
align-items: center;
input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.ternary-radio-label {
padding: 8px 16px;
background: var(--color-grey-900);
border: none;
border-right: 1px solid var(--color-grey-750);
font-size: 13px;
font-weight: 500;
color: var(--color-text);
transition: all 0.2s ease;
cursor: pointer;
display: block;
min-width: 60px;
text-align: center;
}
&:last-child .ternary-radio-label {
border-right: none;
}
input:checked + .ternary-radio-label {
background: var(--color-link);
color: var(--color-text-button);
font-weight: 600;
&.ternary-default {
background: var(--color-grey-600);
color: var(--color-text-button);
}
&:hover {
background: #1a7bc4;
&.ternary-default {
background: var(--color-grey-500);
}
}
}
&:hover .ternary-radio-label {
background: var(--color-grey-800);
}
}
@media (max-width: 480px) {
width: 100%;
.ternary-radio-label {
flex: 1;
min-width: auto;
}
}
}
// Standard radio button styling
input[type="radio"].pure-radio:checked + label,
input[type="radio"].pure-radio:checked {
background: var(--color-link);
color: var(--color-text-button);
}
html[data-darkmode="true"] {
.ternary-radio-group {
.ternary-radio-option {
.ternary-radio-label {
background: var(--color-grey-350);
}
&:hover .ternary-radio-label {
background: var(--color-grey-400);
}
input:checked + .ternary-radio-label {
background: var(--color-link);
color: var(--color-text-button);
&.ternary-default {
background: var(--color-grey-600);
}
&:hover {
background: #1a7bc4;
&.ternary-default {
background: var(--color-grey-500);
}
}
}
}
}
}
@@ -20,7 +20,7 @@
@use "parts/lister_extra"; @use "parts/lister_extra";
@use "parts/socket"; @use "parts/socket";
@use "parts/visualselector"; @use "parts/visualselector";
@use "parts/widgets";
body { body {
color: var(--color-text); color: var(--color-text);
@@ -1130,11 +1130,12 @@ ul {
} }
#realtime-conn-error { #realtime-conn-error {
position: absolute; position: fixed;
bottom: 0; bottom: 0;
left: 30px; left: 0;
background: var(--color-warning); background: var(--color-warning);
padding: 10px; padding: 10px;
font-size: 0.8rem; font-size: 0.8rem;
color: #fff; color: #fff;
opacity: 0.8;
} }
File diff suppressed because one or more lines are too long
+11 -6
View File
@@ -262,11 +262,6 @@ class ChangeDetectionStore:
extras = deepcopy(self.data['watching'][uuid]) extras = deepcopy(self.data['watching'][uuid])
new_uuid = self.add_watch(url=url, extras=extras) new_uuid = self.add_watch(url=url, extras=extras)
watch = self.data['watching'][new_uuid] watch = self.data['watching'][new_uuid]
if self.data['settings']['application'].get('extract_title_as_title') or watch['extract_title_as_title']:
# Because it will be recalculated on the next fetch
self.data['watching'][new_uuid]['title'] = None
return new_uuid return new_uuid
def url_exists(self, url): def url_exists(self, url):
@@ -308,7 +303,6 @@ class ChangeDetectionStore:
'browser_steps', 'browser_steps',
'css_filter', 'css_filter',
'extract_text', 'extract_text',
'extract_title_as_title',
'headers', 'headers',
'ignore_text', 'ignore_text',
'include_filters', 'include_filters',
@@ -323,6 +317,7 @@ class ChangeDetectionStore:
'title', 'title',
'trigger_text', 'trigger_text',
'url', 'url',
'use_page_title_in_list',
'webdriver_js_execute_code', 'webdriver_js_execute_code',
]: ]:
if res.get(k): if res.get(k):
@@ -973,6 +968,16 @@ class ChangeDetectionStore:
f_d.write(zlib.compress(f_j.read())) f_d.write(zlib.compress(f_j.read()))
os.unlink(json_path) os.unlink(json_path)
def update_20(self):
for uuid, watch in self.data['watching'].items():
if self.data['watching'][uuid].get('extract_title_as_title'):
self.data['watching'][uuid]['use_page_title_in_list'] = self.data['watching'][uuid].get('extract_title_as_title')
del self.data['watching'][uuid]['extract_title_as_title']
if self.data['settings']['application'].get('extract_title_as_title'):
self.data['settings']['application']['ui']['use_page_title_in_list'] = self.data['settings']['application'].get('extract_title_as_title')
def add_notification_url(self, notification_url): def add_notification_url(self, notification_url):
logger.debug(f">>> Adding new notification_url - '{notification_url}'") logger.debug(f">>> Adding new notification_url - '{notification_url}'")
@@ -70,7 +70,7 @@
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{watch_title}}' }}</code></td> <td><code>{{ '{{watch_title}}' }}</code></td>
<td>The title of the watch.</td> <td>The page title of the watch, uses &lt;title&gt; if not set, falls back to URL</td>
</tr> </tr>
<tr> <tr>
<td><code>{{ '{{watch_tag}}' }}</code></td> <td><code>{{ '{{watch_tag}}' }}</code></td>
+26 -2
View File
@@ -1,6 +1,13 @@
{% macro render_field(field) %} {% macro render_field(field) %}
<div {% if field.errors %} class="error" {% endif %}>{{ field.label }}</div> <div {% if field.errors or field.top_errors %} class="error" {% endif %}>{{ field.label }}</div>
<div {% if field.errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }} <div {% if field.errors or field.top_errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }}
{% if field.top_errors %}
<ul class="errors top-errors">
{% for error in field.top_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{% if field.errors %} {% if field.errors %}
<ul class=errors> <ul class=errors>
{% for error in field.errors %} {% for error in field.errors %}
@@ -24,6 +31,23 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro render_ternary_field(field, BooleanField=false) %}
{% if BooleanField %}
{% set _ = field.__setattr__('boolean_mode', true) %}
{% endif %}
<div class="ternary-field {% if field.errors %} error {% endif %}">
<div class="ternary-field-label">{{ field.label }}</div>
<div class="ternary-field-widget">{{ field(**kwargs)|safe }}</div>
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}
{% macro render_simple_field(field) %} {% macro render_simple_field(field) %}
<span class="label {% if field.errors %}error{% endif %}">{{ field.label }}</span> <span class="label {% if field.errors %}error{% endif %}">{{ field.label }}</span>
+3 -2
View File
@@ -5,6 +5,7 @@
<meta charset="utf-8" > <meta charset="utf-8" >
<meta name="viewport" content="width=device-width, initial-scale=1.0" > <meta name="viewport" content="width=device-width, initial-scale=1.0" >
<meta name="description" content="Self hosted website change detection." > <meta name="description" content="Self hosted website change detection." >
<meta name="robots" content="noindex">
<title>Change Detection{{extra_title}}</title> <title>Change Detection{{extra_title}}</title>
{% if app_rss_token %} {% if app_rss_token %}
<link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag_uuid %}- {{active_tag.title}}{% endif %}" href="{{ url_for('rss.feed', tag=active_tag_uuid , token=app_rss_token)}}" > <link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag_uuid %}- {{active_tag.title}}{% endif %}" href="{{ url_for('rss.feed', tag=active_tag_uuid , token=app_rss_token)}}" >
@@ -40,7 +41,7 @@
{% endif %} {% endif %}
</head> </head>
<body class=""> <body class="{{extra_classes}}">
<div class="header"> <div class="header">
<div class="pure-menu-fixed" style="width: 100%;"> <div class="pure-menu-fixed" style="width: 100%;">
<div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu"> <div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu">
@@ -236,7 +237,7 @@
<script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script> <script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script>
<div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span>&nbsp;Checking now</span></div> <div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span>&nbsp;Checking now</span></div>
<div id="realtime-conn-error" style="display:none">Offline</div> <div id="realtime-conn-error" style="display:none">Real-time updates offline</div>
</body> </body>
</html> </html>
@@ -55,7 +55,8 @@ def do_test(client, live_server, make_test_use_extra_browser=False):
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': f"extra_browser_{custom_browser_name}", 'fetch_backend': f"extra_browser_{custom_browser_name}",
'webdriver_js_execute_code': '' 'webdriver_js_execute_code': '',
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
@@ -28,6 +28,7 @@ def test_execute_custom_js(client, live_server, measure_memory_usage):
'fetch_backend': "html_webdriver", 'fetch_backend': "html_webdriver",
'webdriver_js_execute_code': 'document.querySelector("button[name=test-button]").click();', 'webdriver_js_execute_code': 'document.querySelector("button[name=test-button]").click();',
'headers': "testheader: yes\buser-agent: MyCustomAgent", 'headers': "testheader: yes\buser-agent: MyCustomAgent",
"time_between_check_use_default": "y",
}, },
follow_redirects=True follow_redirects=True
) )
@@ -27,6 +27,7 @@ def test_preferred_proxy(client, live_server, measure_memory_usage):
"proxy": "proxy-two", "proxy": "proxy-two",
"tags": "", "tags": "",
"url": url, "url": url,
"time_between_check_use_default": "y",
}, },
follow_redirects=True follow_redirects=True
) )
@@ -62,6 +62,7 @@ def test_noproxy_option(client, live_server, measure_memory_usage):
"proxy": "no-proxy", "proxy": "no-proxy",
"tags": "", "tags": "",
"url": url, "url": url,
"time_between_check_use_default": "y",
}, },
follow_redirects=True follow_redirects=True
) )
@@ -44,6 +44,7 @@ def test_proxy_noconnect_custom(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"fetch_backend": "html_webdriver" if os.getenv('PLAYWRIGHT_DRIVER_URL') or os.getenv("WEBDRIVER_URL") else "html_requests", "fetch_backend": "html_webdriver" if os.getenv('PLAYWRIGHT_DRIVER_URL') or os.getenv("WEBDRIVER_URL") else "html_requests",
"proxy": "ui-0custom-test-proxy", "proxy": "ui-0custom-test-proxy",
"time_between_check_use_default": "y",
} }
res = client.post( res = client.post(
@@ -66,6 +66,7 @@ def test_socks5(client, live_server, measure_memory_usage):
"proxy": "ui-0socks5proxy", "proxy": "ui-0socks5proxy",
"tags": "", "tags": "",
"url": test_url, "url": test_url,
"time_between_check_use_default": "y",
}, },
follow_redirects=True follow_redirects=True
) )
@@ -53,6 +53,7 @@ def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage)
"proxy": "socks5proxy", "proxy": "socks5proxy",
"tags": "", "tags": "",
"url": test_url, "url": test_url,
"time_between_check_use_default": "y",
}, },
follow_redirects=True follow_redirects=True
) )
@@ -157,7 +157,8 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
data={ data={
"url": test_url, "url": test_url,
"notification_format": 'HTML', "notification_format": 'HTML',
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -61,7 +61,8 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory
data={"trigger_text": 'The golden line', data={"trigger_text": 'The golden line',
"url": test_url, "url": test_url,
'fetch_backend': "html_requests", 'fetch_backend': "html_requests",
'filter_text_removed': 'y'}, 'filter_text_removed': 'y',
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -154,7 +155,8 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
'processor': 'text_json_diff', 'processor': 'text_json_diff',
'fetch_backend': "html_requests", 'fetch_backend': "html_requests",
'filter_text_removed': '', 'filter_text_removed': '',
'filter_text_added': 'y'}, 'filter_text_added': 'y',
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
+11 -4
View File
@@ -311,7 +311,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
"value": "." # contains anything "value": "." # contains anything
} }
], ],
"conditions_match_logic": "ALL" "conditions_match_logic": "ALL",
} }
), ),
headers={'content-type': 'application/json', 'x-api-key': api_key}, headers={'content-type': 'application/json', 'x-api-key': api_key},
@@ -328,6 +328,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
) )
watch_uuid = list(res.json.keys())[0] watch_uuid = list(res.json.keys())[0]
assert not res.json[watch_uuid].get('viewed'), 'A newly created watch can only be unviewed'
# Check in the edit page just to be sure # Check in the edit page just to be sure
res = client.get( res = client.get(
@@ -341,7 +342,12 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
res = client.put( res = client.put(
url_for("watch", uuid=watch_uuid), url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'}, 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'}}), data=json.dumps({
"title": "new title",
'time_between_check': {'minutes': 552},
'headers': {'cookie': 'all eaten'},
'last_viewed': int(time.time())
}),
) )
assert res.status_code == 200, "HTTP PUT update was sent OK" assert res.status_code == 200, "HTTP PUT update was sent OK"
@@ -351,6 +357,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
headers={'x-api-key': api_key} headers={'x-api-key': api_key}
) )
assert res.json.get('title') == 'new title' assert res.json.get('title') == 'new title'
assert res.json.get('viewed'), 'With the timestamp greater than "changed" a watch can be updated to viewed'
# Check in the edit page just to be sure # Check in the edit page just to be sure
res = client.get( res = client.get(
@@ -383,13 +390,13 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
def test_api_import(client, live_server, measure_memory_usage): def test_api_import(client, live_server, measure_memory_usage):
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
res = client.post( res = client.post(
url_for("import") + "?tag=import-test", url_for("import") + "?tag=import-test",
data='https://website1.com\r\nhttps://website2.com', data='https://website1.com\r\nhttps://website2.com',
headers={'x-api-key': api_key}, headers={'x-api-key': api_key, 'content-type': 'text/plain'},
follow_redirects=True follow_redirects=True
) )
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
OpenAPI validation tests for ChangeDetection.io API
This test file specifically verifies that OpenAPI validation is working correctly
by testing various scenarios that should trigger validation errors.
"""
import time
import json
from flask import url_for
from .util import live_server_setup, wait_for_all_checks
def test_openapi_validation_invalid_content_type_on_create_watch(client, live_server, measure_memory_usage):
"""Test that creating a watch with invalid content-type triggers OpenAPI validation error."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# Try to create a watch with JSON data but without proper content-type header
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": "https://example.com", "title": "Test Watch"}),
headers={'x-api-key': api_key}, # Missing 'content-type': 'application/json'
follow_redirects=True
)
# Should get 400 error due to OpenAPI validation failure
assert res.status_code == 400, f"Expected 400 but got {res.status_code}"
assert b"OpenAPI validation failed" in res.data, "Should contain OpenAPI validation error message"
def test_openapi_validation_missing_required_field_create_watch(client, live_server, measure_memory_usage):
"""Test that creating a watch without required URL field triggers OpenAPI validation error."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# Try to create a watch without the required 'url' field
res = client.post(
url_for("createwatch"),
data=json.dumps({"title": "Test Watch Without URL"}), # Missing required 'url' field
headers={'x-api-key': api_key, 'content-type': 'application/json'},
follow_redirects=True
)
# Should get 400 error due to missing required field
assert res.status_code == 400, f"Expected 400 but got {res.status_code}"
assert b"OpenAPI validation failed" in res.data, "Should contain OpenAPI validation error message"
def test_openapi_validation_invalid_field_in_request_body(client, live_server, measure_memory_usage):
"""Test that including invalid fields triggers OpenAPI validation error."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# First create a valid watch
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": "https://example.com", "title": "Test Watch"}),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
follow_redirects=True
)
assert res.status_code == 201, "Watch creation should succeed"
# Get the watch list to find the UUID
res = client.get(
url_for("createwatch"),
headers={'x-api-key': api_key}
)
assert res.status_code == 200
watch_uuid = list(res.json.keys())[0]
# Now try to update the watch with an invalid field
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({
"title": "Updated title",
"invalid_field_that_doesnt_exist": "this should cause validation error"
}),
)
# Should get 400 error due to invalid field (this will be caught by internal validation)
# Note: This tests the flow where OpenAPI validation passes but internal validation catches it
assert res.status_code == 400, f"Expected 400 but got {res.status_code}"
assert b"Additional properties are not allowed" in res.data, "Should contain validation error about additional properties"
def test_openapi_validation_import_wrong_content_type(client, live_server, measure_memory_usage):
"""Test that import endpoint with wrong content-type triggers OpenAPI validation error."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# Try to import URLs with JSON content-type instead of text/plain
res = client.post(
url_for("import") + "?tag=test-import",
data='https://website1.com\nhttps://website2.com',
headers={'x-api-key': api_key, 'content-type': 'application/json'}, # Wrong content-type
follow_redirects=True
)
# Should get 400 error due to content-type mismatch
assert res.status_code == 400, f"Expected 400 but got {res.status_code}"
assert b"OpenAPI validation failed" in res.data, "Should contain OpenAPI validation error message"
def test_openapi_validation_import_correct_content_type_succeeds(client, live_server, measure_memory_usage):
"""Test that import endpoint with correct content-type succeeds (positive test)."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# Import URLs with correct text/plain content-type
res = client.post(
url_for("import") + "?tag=test-import",
data='https://website1.com\nhttps://website2.com',
headers={'x-api-key': api_key, 'content-type': 'text/plain'}, # Correct content-type
follow_redirects=True
)
# Should succeed
assert res.status_code == 200, f"Expected 200 but got {res.status_code}"
assert len(res.json) == 2, "Should import 2 URLs"
def test_openapi_validation_get_requests_bypass_validation(client, live_server, measure_memory_usage):
"""Test that GET requests bypass OpenAPI validation entirely."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# Disable API token requirement first
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
# Make GET request to list watches - should succeed even without API key or content-type
res = client.get(url_for("createwatch")) # No headers needed for GET
assert res.status_code == 200, f"GET requests should succeed without OpenAPI validation, got {res.status_code}"
# Should return JSON with watch list (empty in this case)
assert isinstance(res.json, dict), "Should return JSON dictionary for watch list"
def test_openapi_validation_create_tag_missing_required_title(client, live_server, measure_memory_usage):
"""Test that creating a tag without required title triggers OpenAPI validation error."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# Try to create a tag without the required 'title' field
res = client.post(
url_for("tag"),
data=json.dumps({"notification_urls": ["mailto:test@example.com"]}), # Missing required 'title' field
headers={'x-api-key': api_key, 'content-type': 'application/json'},
follow_redirects=True
)
# Should get 400 error due to missing required field
assert res.status_code == 400, f"Expected 400 but got {res.status_code}"
assert b"OpenAPI validation failed" in res.data, "Should contain OpenAPI validation error message"
def test_openapi_validation_watch_update_allows_partial_updates(client, live_server, measure_memory_usage):
"""Test that watch updates allow partial updates without requiring all fields (positive test)."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
# First create a valid watch
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": "https://example.com", "title": "Test Watch"}),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
follow_redirects=True
)
assert res.status_code == 201, "Watch creation should succeed"
# Get the watch list to find the UUID
res = client.get(
url_for("createwatch"),
headers={'x-api-key': api_key}
)
assert res.status_code == 200
watch_uuid = list(res.json.keys())[0]
# Update only the title (partial update) - should succeed
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({"title": "Updated Title Only"}), # Only updating title, not URL
)
# Should succeed because UpdateWatch schema allows partial updates
assert res.status_code == 200, f"Partial updates should succeed, got {res.status_code}"
# Verify the update worked
res = client.get(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key}
)
assert res.status_code == 200
assert res.json.get('title') == 'Updated Title Only', "Title should be updated"
assert res.json.get('url') == 'https://example.com', "URL should remain unchanged"
+1 -1
View File
@@ -23,7 +23,7 @@ def test_basic_auth(client, live_server, measure_memory_usage):
# Check form validation # Check form validation
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"include_filters": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
+17 -11
View File
@@ -89,7 +89,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
assert b'CDATA' in res.data assert b'CDATA' in res.data
assert expected_url.encode('utf-8') in res.data assert expected_url.encode('utf-8') in res.data
#
# Following the 'diff' link, it should no longer display as 'unviewed' even after we recheck it a few times # Following the 'diff' link, it should no longer display as 'unviewed' even after we recheck it a few times
res = client.get(url_for("ui.ui_views.diff_history_page", uuid=uuid)) res = client.get(url_for("ui.ui_views.diff_history_page", uuid=uuid))
assert b'selected=""' in res.data, "Confirm diff history page loaded" assert b'selected=""' in res.data, "Confirm diff history page loaded"
@@ -104,26 +104,34 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
wait_for_all_checks(client) wait_for_all_checks(client)
# Do this a few times.. ensures we dont accidently set the status
for n in range(2):
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
# Do this a few times.. ensures we don't accidently set the status
for n in range(2):
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
# Give the thread time to pick it up # Give the thread time to pick it up
wait_for_all_checks(client) wait_for_all_checks(client)
# It should report nothing found (no new 'unviewed' class) # It should report nothing found (no new 'unviewed' class)
res = client.get(url_for("watchlist.index")) res = client.get(url_for("watchlist.index"))
assert b'unviewed' not in res.data assert b'unviewed' not in res.data
assert b'class="has-unviewed' not in res.data assert b'class="has-unviewed' not in res.data
assert b'head title' not in res.data # Should not be present because this is off by default assert b'head title' in res.data # Should be ON by default
assert b'test-endpoint' in res.data assert b'test-endpoint' in res.data
set_original_response() # Recheck it but only with a title change, content wasnt changed
set_original_response(extra_title=" and more")
# Enable auto pickup of <title> in settings 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'head title and more' in res.data
# disable <title> pickup
res = client.post( res = client.post(
url_for("settings.settings_page"), url_for("settings.settings_page"),
data={"application-extract_title_as_title": "1", "requests-time_between_check-minutes": 180, data={"application-ui-use_page_title_in_list": "", "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True
) )
@@ -134,16 +142,14 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
res = client.get(url_for("watchlist.index")) res = client.get(url_for("watchlist.index"))
assert b'unviewed' in res.data assert b'unviewed' in res.data
assert b'class="has-unviewed' in res.data assert b'class="has-unviewed' in res.data
assert b'head title' not in res.data # should now be off
# It should have picked up the <title>
assert b'head title' in res.data
# Be sure the last_viewed is going to be greater than the last snapshot # Be sure the last_viewed is going to be greater than the last snapshot
time.sleep(1) time.sleep(1)
# hit the mark all viewed link # hit the mark all viewed link
res = client.get(url_for("ui.mark_all_viewed"), follow_redirects=True) res = client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
time.sleep(0.2)
assert b'class="has-unviewed' not in res.data assert b'class="has-unviewed' not in res.data
assert b'unviewed' not in res.data assert b'unviewed' not in res.data
@@ -86,7 +86,8 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"text_should_not_be_present": ignore_text, data={"text_should_not_be_present": ignore_text,
"url": test_url, "url": test_url,
'fetch_backend': "html_requests" 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
+3 -1
View File
@@ -105,6 +105,7 @@ def test_conditions_with_text_and_number(client, live_server):
"conditions-5-operator": "contains_regex", "conditions-5-operator": "contains_regex",
"conditions-5-field": "page_filtered_text", "conditions-5-field": "page_filtered_text",
"conditions-5-value": "\d", "conditions-5-value": "\d",
"time_between_check_use_default": "y",
}, },
follow_redirects=True follow_redirects=True
) )
@@ -288,7 +289,8 @@ def test_lev_conditions_plugin(client, live_server, measure_memory_usage):
"conditions_match_logic": CONDITIONS_MATCH_LOGIC_DEFAULT, # ALL = AND logic "conditions_match_logic": CONDITIONS_MATCH_LOGIC_DEFAULT, # ALL = AND logic
"conditions-0-field": "levenshtein_ratio", "conditions-0-field": "levenshtein_ratio",
"conditions-0-operator": "<", "conditions-0-operator": "<",
"conditions-0-value": "0.8" # needs to be more of a diff to trigger a change "conditions-0-value": "0.8", # needs to be more of a diff to trigger a change
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
+5 -3
View File
@@ -95,7 +95,7 @@ def test_check_markup_include_filters_restriction(client, live_server, measure_m
# Add our URL to the import page # Add our URL to the import page
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": include_filters, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"include_filters": include_filters, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -154,7 +154,8 @@ def test_check_multiple_filters(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -208,7 +209,8 @@ def test_filter_is_empty_help_suggestion(client, live_server, measure_memory_usa
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -171,6 +171,7 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
"tags": "", "tags": "",
"headers": "", "headers": "",
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"time_between_check_use_default": "y",
}, },
follow_redirects=True, follow_redirects=True,
) )
@@ -245,6 +246,7 @@ body > table > tr:nth-child(3) > td:nth-child(3)""",
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"time_between_check_use_default": "y",
}, },
follow_redirects=True, follow_redirects=True,
) )
@@ -127,7 +127,8 @@ def test_low_level_errors_clear_correctly(client, live_server, measure_memory_us
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={ data={
"url": test_url, "url": test_url,
"fetch_backend": "html_requests"}, "fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
@@ -95,7 +95,8 @@ def test_check_filter_multiline(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': "html_requests" 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
@@ -149,7 +150,8 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': "html_requests" 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
@@ -222,7 +224,8 @@ def test_regex_error_handling(client, live_server, measure_memory_usage):
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"extract_text": '/something bad\d{3/XYZ', data={"extract_text": '/something bad\d{3/XYZ',
"url": test_url, "url": test_url,
"fetch_backend": "html_requests"}, "fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
@@ -94,7 +94,8 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se
"title": "my title", "title": "my title",
"headers": "", "headers": "",
"include_filters": '.ticket-available', "include_filters": '.ticket-available',
"fetch_backend": "html_requests"}) "fetch_backend": "html_requests",
"time_between_check_use_default": "y"})
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
@@ -72,6 +72,7 @@ def run_filter_test(client, live_server, content_filter):
"notification_format": "Text", "notification_format": "Text",
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"filter_failure_notification_send": 'y', "filter_failure_notification_send": 'y',
"time_between_check_use_default": "y",
"headers": "", "headers": "",
"tags": "my tag", "tags": "my tag",
"title": "my title 123", "title": "my title 123",
+2 -1
View File
@@ -424,7 +424,8 @@ def test_order_of_filters_tag_filter_and_watch_filter(client, live_server, measu
"url": test_url, "url": test_url,
"tags": "test-tag-keep-order", "tags": "test-tag-keep-order",
"headers": "", "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
+2 -2
View File
@@ -111,7 +111,7 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
# Add our URL to the import page # Add our URL to the import page
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"ignore_text": ignore_text, "url": test_url, 'fetch_backend': "html_requests"}, data={"ignore_text": ignore_text, "url": test_url, 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -205,7 +205,7 @@ def _run_test_global_ignore(client, as_source=False, extra_ignore=""):
#Adding some ignore text should not trigger a change #Adding some ignore text should not trigger a change
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), 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"}, data={"ignore_text": "something irrelevent but just to check", "url": test_url, 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -108,7 +108,7 @@ def test_403_page_check_works_with_ignore_status_code(client, live_server, measu
# Add our URL to the import page # Add our URL to the import page
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"ignore_status_codes": "y", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"ignore_status_codes": "y", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -257,7 +257,8 @@ def check_json_filter(json_filter, client, live_server):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
"fetch_backend": "html_requests" "fetch_backend": "html_requests",
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
@@ -328,7 +329,8 @@ def check_json_filter_bool_val(json_filter, client, live_server):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
"fetch_backend": "html_requests" "fetch_backend": "html_requests",
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
@@ -393,7 +395,8 @@ def check_json_ext_filter(json_filter, client, live_server):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
"fetch_backend": "html_requests" "fetch_backend": "html_requests",
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
@@ -38,6 +38,7 @@ def test_content_filter_live_preview(client, live_server, measure_memory_usage):
"ignore_text": "something to ignore", "ignore_text": "something to ignore",
"trigger_text": "something to trigger", "trigger_text": "something to trigger",
"url": test_url, "url": test_url,
"time_between_check_use_default": "y",
}, },
follow_redirects=True follow_redirects=True
) )
+4 -2
View File
@@ -108,7 +108,8 @@ def test_check_notification(client, live_server, measure_memory_usage):
"tags": "my tag, my second tag", "tags": "my tag, my second tag",
"title": "my title", "title": "my title",
"headers": "", "headers": "",
"fetch_backend": "html_requests"}) "fetch_backend": "html_requests",
"time_between_check_use_default": "y"})
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
@@ -225,7 +226,8 @@ def test_check_notification(client, live_server, measure_memory_usage):
"notification_title": '', "notification_title": '',
"notification_body": '', "notification_body": '',
"notification_format": default_notification_format, "notification_format": default_notification_format,
"fetch_backend": "html_requests"}, "fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -36,7 +36,8 @@ def test_check_notification_error_handling(client, live_server, measure_memory_u
"title": "", "title": "",
"headers": "", "headers": "",
"time_between_check-minutes": "180", "time_between_check-minutes": "180",
"fetch_backend": "html_requests"}, "fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
+18 -9
View File
@@ -44,7 +44,8 @@ def test_headers_in_request(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',
"headers": "jinja2:{{ 1+1 }}\nxxx:ooo\ncool:yeah\r\ncookie:"+cookie_header}, "headers": "jinja2:{{ 1+1 }}\nxxx:ooo\ncool:yeah\r\ncookie:"+cookie_header,
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -109,7 +110,8 @@ def test_body_in_request(client, live_server, measure_memory_usage):
"tags": "", "tags": "",
"method": "POST", "method": "POST",
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"body": "something something"}, "body": "something something",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -126,7 +128,8 @@ def test_body_in_request(client, live_server, measure_memory_usage):
"tags": "", "tags": "",
"method": "POST", "method": "POST",
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"body": body_value}, "body": body_value,
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -172,7 +175,8 @@ def test_body_in_request(client, live_server, measure_memory_usage):
"tags": "", "tags": "",
"method": "GET", "method": "GET",
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"body": "invalid"}, "body": "invalid",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Body must be empty when Request Method is set to GET" in res.data assert b"Body must be empty when Request Method is set to GET" in res.data
@@ -211,7 +215,8 @@ def test_method_in_request(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"method": "invalid"}, "method": "invalid",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Not a valid choice" in res.data assert b"Not a valid choice" in res.data
@@ -223,7 +228,8 @@ def test_method_in_request(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"method": "PATCH"}, "method": "PATCH",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -297,7 +303,8 @@ def test_ua_global_override(client, live_server, measure_memory_usage):
"tags": "testtag", "tags": "testtag",
"fetch_backend": 'html_requests', "fetch_backend": 'html_requests',
# Important - also test case-insensitive # Important - also test case-insensitive
"headers": "User-AGent: agent-from-watch"}, "headers": "User-AGent: agent-from-watch",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -365,7 +372,8 @@ def test_headers_textfile_in_request(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "testtag", "tags": "testtag",
"fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',
"headers": "xxx:ooo\ncool:yeah\r\n"}, "headers": "xxx:ooo\ncool:yeah\r\n",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -440,7 +448,8 @@ def test_headers_validation(client, live_server):
data={ data={
"url": test_url, "url": test_url,
"fetch_backend": 'html_requests', "fetch_backend": 'html_requests',
"headers": "User-AGent agent-from-watch\r\nsadfsadfsadfsdaf\r\n:foobar"}, "headers": "User-AGent agent-from-watch\r\nsadfsadfsadfsdaf\r\n:foobar",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
@@ -121,7 +121,7 @@ def test_itemprop_price_change(client, live_server):
set_original_response(props_markup=instock_props[0], price='120.45') set_original_response(props_markup=instock_props[0], price='120.45')
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"restock_settings-follow_price_changes": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"restock_settings-follow_price_changes": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -155,7 +155,8 @@ def _run_test_minmax_limit(client, extra_watch_edit_form):
"url": test_url, "url": test_url,
"headers": "", "headers": "",
"time_between_check-hours": 5, "time_between_check-hours": 5,
'fetch_backend': "html_requests" 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
} }
data.update(extra_watch_edit_form) data.update(extra_watch_edit_form)
res = client.post( res = client.post(
@@ -278,7 +279,8 @@ def test_itemprop_percent_threshold(client, live_server):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': "html_requests" 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
+1
View File
@@ -158,6 +158,7 @@ def test_rss_xpath_filtering(client, live_server, measure_memory_usage):
"proxy": "no-proxy", "proxy": "no-proxy",
"tags": "", "tags": "",
"url": test_url, "url": test_url,
"time_between_check_use_default": "y",
}, },
follow_redirects=True follow_redirects=True
) )
+50 -4
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import time import time
from copy import copy
from datetime import datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from flask import url_for from flask import url_for
from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
from ..forms import REQUIRE_ATLEAST_ONE_TIME_PART_MESSAGE_DEFAULT, REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT
# def test_setup(client, live_server): # def test_setup(client, live_server):
# live_server_setup(live_server) # Setup on conftest per function # live_server_setup(live_server) # Setup on conftest per function
@@ -42,11 +45,12 @@ def test_check_basic_scheduler_functionality(client, live_server, measure_memory
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) 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 # Setup all the days of the weeks using XXX as the placeholder for monday/tuesday/etc
last_check = copy(live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'])
tpl = { tpl = {
"time_schedule_limit-XXX-start_time": "00:00", "time_schedule_limit-XXX-start_time": "00:00",
"time_schedule_limit-XXX-duration-hours": 24, "time_schedule_limit-XXX-duration-hours": 24,
"time_schedule_limit-XXX-duration-minutes": 0, "time_schedule_limit-XXX-duration-minutes": 0,
"time_between_check-seconds": 1,
"time_schedule_limit-XXX-enabled": '', # All days are turned off "time_schedule_limit-XXX-enabled": '', # All days are turned off
"time_schedule_limit-enabled": 'y', # Scheduler is enabled, all days however are off. "time_schedule_limit-enabled": 'y', # Scheduler is enabled, all days however are off.
} }
@@ -58,13 +62,13 @@ def test_check_basic_scheduler_functionality(client, live_server, measure_memory
new_key = key.replace("XXX", day) new_key = key.replace("XXX", day)
scheduler_data[new_key] = value scheduler_data[new_key] = value
last_check = live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked']
data = { data = {
"url": test_url, "url": test_url,
"fetch_backend": "html_requests" "fetch_backend": "html_requests",
"time_between_check_use_default": "" # no
} }
data.update(scheduler_data) data.update(scheduler_data)
time.sleep(1)
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data=data, data=data,
@@ -77,6 +81,7 @@ def test_check_basic_scheduler_functionality(client, live_server, measure_memory
# "Edit" should not trigger a check because it's not enabled in the schedule. # "Edit" should not trigger a check because it's not enabled in the schedule.
time.sleep(2) time.sleep(2)
# "time_schedule_limit-XXX-enabled": '', # All days are turned off, therefor, nothing should happen here..
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] == last_check assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] == last_check
# Enabling today in Kiritimati should work flawless # Enabling today in Kiritimati should work flawless
@@ -177,3 +182,44 @@ def test_check_basic_global_scheduler_functionality(client, live_server, measure
# Cleanup everything # Cleanup everything
res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data assert b'Deleted' in res.data
def test_validation_time_interval_field(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
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',
"time_between_check_use_default": ""
},
follow_redirects=True
)
assert REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT.encode('utf-8') in res.data
# Now set atleast something
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",
"time_between_check-minutes": 1,
"time_between_check_use_default": ""
},
follow_redirects=True
)
assert REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT.encode('utf-8') not in res.data
+2 -2
View File
@@ -27,7 +27,7 @@ def test_basic_search(client, live_server, measure_memory_usage):
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"title": "xxx-title", "url": urls[0], "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"title": "xxx-title", "url": urls[0], "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -62,7 +62,7 @@ def test_search_in_tag_limit(client, live_server, measure_memory_usage):
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"title": "xxx-title", "url": urls[0].split(' ')[0], "tags": urls[0].split(' ')[1], "headers": "", data={"title": "xxx-title", "url": urls[0].split(' ')[0], "tags": urls[0].split(' ')[1], "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
+4 -2
View File
@@ -41,7 +41,8 @@ def test_bad_access(client, live_server, measure_memory_usage):
"tags": "", "tags": "",
"method": "GET", "method": "GET",
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"body": ""}, "body": "",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
@@ -150,7 +151,8 @@ def test_xss_watch_last_error(client, live_server, measure_memory_usage):
data={ data={
"include_filters": '<a href="https://foobar"></a><script>alert(123);</script>', "include_filters": '<a href="https://foobar"></a><script>alert(123);</script>',
"url": url_for('test_endpoint', _external=True), "url": url_for('test_endpoint', _external=True),
'fetch_backend': "html_requests" 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
+1 -1
View File
@@ -29,7 +29,7 @@ def test_share_watch(client, live_server, measure_memory_usage):
# Add our URL to the import page # Add our URL to the import page
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": include_filters, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"include_filters": include_filters, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
+1 -1
View File
@@ -77,7 +77,7 @@ def test_check_ignore_elements(client, live_server, measure_memory_usage):
client.post( client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), 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"}, data={"include_filters": 'span,p', "url": test_url, "tags": "", "subtractive_selectors": ".foobar-detection", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
+2 -1
View File
@@ -81,7 +81,8 @@ def test_trigger_functionality(client, live_server, measure_memory_usage):
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"trigger_text": trigger_text, data={"trigger_text": trigger_text,
"url": test_url, "url": test_url,
"fetch_backend": "html_requests"}, "fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -49,7 +49,8 @@ def test_trigger_regex_functionality(client, live_server, measure_memory_usage):
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"trigger_text": '/something \d{3}/', data={"trigger_text": '/something \d{3}/',
"url": test_url, "url": test_url,
"fetch_backend": "html_requests"}, "fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
wait_for_all_checks(client) wait_for_all_checks(client)
@@ -50,7 +50,8 @@ def test_trigger_regex_functionality_with_filter(client, live_server, measure_me
data={"trigger_text": "/cool.stuff/", data={"trigger_text": "/cool.stuff/",
"url": test_url, "url": test_url,
"include_filters": '#in-here', "include_filters": '#in-here',
"fetch_backend": "html_requests"}, "fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
+6 -3
View File
@@ -92,7 +92,8 @@ def test_unique_lines_functionality(client, live_server, measure_memory_usage):
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"check_unique_lines": "y", data={"check_unique_lines": "y",
"url": test_url, "url": test_url,
"fetch_backend": "html_requests"}, "fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -140,7 +141,8 @@ def test_sort_lines_functionality(client, live_server, measure_memory_usage):
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"sort_text_alphabetically": "n", data={"sort_text_alphabetically": "n",
"url": test_url, "url": test_url,
"fetch_backend": "html_requests"}, "fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -192,7 +194,8 @@ def test_extra_filters(client, live_server, measure_memory_usage):
"trim_text_whitespace": "y", "trim_text_whitespace": "y",
"sort_text_alphabetically": "", # leave this OFF for testing "sort_text_alphabetically": "", # leave this OFF for testing
"url": test_url, "url": test_url,
"fetch_backend": "html_requests"}, "fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -28,7 +28,8 @@ def test_check_watch_field_storage(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "woohoo", "tags": "woohoo",
"headers": "curl:foo", "headers": "curl:foo",
'fetch_backend': "html_requests" 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
+18 -13
View File
@@ -92,7 +92,7 @@ def test_check_xpath_filter_utf8(client, live_server, measure_memory_usage):
wait_for_all_checks(client) wait_for_all_checks(client)
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"include_filters": filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -146,7 +146,7 @@ def test_check_xpath_text_function_utf8(client, live_server, measure_memory_usag
wait_for_all_checks(client) wait_for_all_checks(client)
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"include_filters": filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -188,7 +188,7 @@ def test_check_markup_xpath_filter_restriction(client, live_server, measure_memo
# Add our URL to the import page # Add our URL to the import page
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": xpath_filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"include_filters": xpath_filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"Updated watch." in res.data assert b"Updated watch." in res.data
@@ -226,7 +226,7 @@ def test_xpath_validation(client, live_server, measure_memory_usage):
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": "/something horrible", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"include_filters": "/something horrible", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"is not a valid XPath expression" in res.data assert b"is not a valid XPath expression" in res.data
@@ -247,7 +247,7 @@ def test_xpath23_prefix_validation(client, live_server, measure_memory_usage):
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": "xpath:/something horrible", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"include_filters": "xpath:/something horrible", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"is not a valid XPath expression" in res.data assert b"is not a valid XPath expression" in res.data
@@ -298,7 +298,7 @@ def test_xpath1_lxml(client, live_server, measure_memory_usage):
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": "xpath1://title/text()", "url": test_url, "tags": "", "headers": "", data={"include_filters": "xpath1://title/text()", "url": test_url, "tags": "", "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
@@ -331,7 +331,7 @@ def test_xpath1_validation(client, live_server, measure_memory_usage):
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": "xpath1:/something horrible", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, data={"include_filters": "xpath1:/something horrible", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
assert b"is not a valid XPath expression" in res.data assert b"is not a valid XPath expression" in res.data
@@ -359,7 +359,7 @@ def test_check_with_prefix_include_filters(client, live_server, measure_memory_u
res = client.post( res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": "xpath://*[contains(@class, 'sametext')]", "url": test_url, "tags": "", "headers": "", data={"include_filters": "xpath://*[contains(@class, 'sametext')]", "url": test_url, "tags": "", "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
@@ -413,7 +413,8 @@ def test_various_rules(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
wait_for_all_checks(client) wait_for_all_checks(client)
@@ -444,7 +445,8 @@ def test_xpath_20(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
@@ -481,7 +483,8 @@ def test_xpath_20_function_count(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
@@ -517,7 +520,8 @@ def test_xpath_20_function_count2(client, live_server, measure_memory_usage):
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
@@ -554,7 +558,8 @@ def test_xpath_20_function_string_join_matches(client, live_server, measure_memo
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"headers": "", "headers": "",
'fetch_backend': "html_requests"}, 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
) )
+3 -3
View File
@@ -6,9 +6,9 @@ from flask import url_for
import logging import logging
import time import time
def set_original_response(): def set_original_response(extra_title=''):
test_return_data = """<html> test_return_data = f"""<html>
<head><title>head title</title></head> <head><title>head title{extra_title}</title></head>
<body> <body>
Some initial text<br> Some initial text<br>
<p>Which is across multiple lines</p> <p>Which is across multiple lines</p>
@@ -36,6 +36,7 @@ def test_visual_selector_content_ready(client, live_server, measure_memory_usage
# For now, cookies doesnt work in headers because it must be a full cookiejar object # For now, cookies doesnt work in headers because it must be a full cookiejar object
'headers': "testheader: yes\buser-agent: MyCustomAgent", 'headers': "testheader: yes\buser-agent: MyCustomAgent",
'fetch_backend': "html_webdriver", 'fetch_backend': "html_webdriver",
"time_between_check_use_default": "y",
}, },
follow_redirects=True follow_redirects=True
) )
@@ -116,6 +117,7 @@ def test_basic_browserstep(client, live_server, measure_memory_usage):
'browser_steps-1-optional_value': '', 'browser_steps-1-optional_value': '',
# For now, cookies doesnt work in headers because it must be a full cookiejar object # For now, cookies doesnt work in headers because it must be a full cookiejar object
'headers': "testheader: yes\buser-agent: MyCustomAgent", 'headers': "testheader: yes\buser-agent: MyCustomAgent",
"time_between_check_use_default": "y",
}, },
follow_redirects=True follow_redirects=True
) )
@@ -167,7 +169,8 @@ def test_non_200_errors_report_browsersteps(client, live_server):
'fetch_backend': "html_webdriver", 'fetch_backend': "html_webdriver",
'browser_steps-0-operation': 'Click element', 'browser_steps-0-operation': 'Click element',
'browser_steps-0-selector': 'button[name=test-button]', 'browser_steps-0-selector': 'button[name=test-button]',
'browser_steps-0-optional_value': '' 'browser_steps-0-optional_value': '',
"time_between_check_use_default": "y"
}, },
follow_redirects=True follow_redirects=True
) )
+3
View File
@@ -0,0 +1,3 @@
from .ternary_boolean import TernaryNoneBooleanWidget, TernaryNoneBooleanField
__all__ = ['TernaryNoneBooleanWidget', 'TernaryNoneBooleanField']
@@ -0,0 +1,104 @@
from wtforms import Field
from wtforms import widgets
from markupsafe import Markup
class TernaryNoneBooleanWidget:
"""
A widget that renders a horizontal radio button group with either two options (Yes/No)
or three options (Yes/No/Default), depending on the field's boolean_mode setting.
"""
def __call__(self, field, **kwargs):
html = ['<div class="ternary-radio-group pure-form">']
field_id = kwargs.pop('id', field.id)
boolean_mode = getattr(field, 'boolean_mode', False)
# Get custom text or use defaults
yes_text = getattr(field, 'yes_text', 'Yes')
no_text = getattr(field, 'no_text', 'No')
none_text = getattr(field, 'none_text', 'Main settings')
# True option
checked_true = ' checked' if field.data is True else ''
html.append(f'''
<label class="ternary-radio-option">
<input type="radio" name="{field.name}" value="true" id="{field_id}_true"{checked_true} class="pure-radio">
<span class="ternary-radio-label pure-button-primary">{yes_text}</span>
</label>
''')
# False option
checked_false = ' checked' if field.data is False else ''
html.append(f'''
<label class="ternary-radio-option">
<input type="radio" name="{field.name}" value="false" id="{field_id}_false"{checked_false} class="pure-radio">
<span class="ternary-radio-label">{no_text}</span>
</label>
''')
# None option (only show if not in boolean mode)
if not boolean_mode:
checked_none = ' checked' if field.data is None else ''
html.append(f'''
<label class="ternary-radio-option">
<input type="radio" name="{field.name}" value="none" id="{field_id}_none"{checked_none} class="pure-radio">
<span class="ternary-radio-label ternary-default">{none_text}</span>
</label>
''')
html.append('</div>')
return Markup(''.join(html))
class TernaryNoneBooleanField(Field):
"""
A field that can handle True, False, or None values, represented as a horizontal radio group.
When boolean_mode=True, it acts like a BooleanField (only Yes/No options).
When boolean_mode=False (default), it shows Yes/No/Default options.
Custom text can be provided for each option:
- yes_text: Text for True option (default: "Yes")
- no_text: Text for False option (default: "No")
- none_text: Text for None option (default: "Default")
"""
widget = TernaryNoneBooleanWidget()
def __init__(self, label=None, validators=None, false_values=None, boolean_mode=False,
yes_text="Yes", no_text="No", none_text="Main settings", **kwargs):
super(TernaryNoneBooleanField, self).__init__(label, validators, **kwargs)
self.boolean_mode = boolean_mode
self.yes_text = yes_text
self.no_text = no_text
self.none_text = none_text
if false_values is None:
self.false_values = {'false', ''}
else:
self.false_values = false_values
def process_formdata(self, valuelist):
if not valuelist or not valuelist[0]:
# In boolean mode, default to False instead of None
self.data = False if self.boolean_mode else None
elif valuelist[0].lower() == 'true':
self.data = True
elif valuelist[0].lower() == 'false':
self.data = False
elif valuelist[0].lower() == 'none':
# In boolean mode, treat 'none' as False
self.data = False if self.boolean_mode else None
else:
self.data = False if self.boolean_mode else None
def _value(self):
if self.data is True:
return 'true'
elif self.data is False:
return 'false'
else:
# In boolean mode, None should be treated as False
if self.boolean_mode:
return 'false'
else:
return 'none'
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
from changedetectionio.widgets import TernaryNoneBooleanField
from wtforms import Form
class TestForm(Form):
# Default text
default_field = TernaryNoneBooleanField('Default Field', default=None)
# Custom text with HTML icons
notification_field = TernaryNoneBooleanField(
'Notifications',
default=False,
yes_text='🔕 Muted',
no_text='🔔 Unmuted',
none_text='⚙️ System default'
)
# HTML with styling
styled_field = TernaryNoneBooleanField(
'Status',
default=None,
yes_text='<strong style="color: green;">✅ Active</strong>',
no_text='<strong style="color: red;">❌ Inactive</strong>',
none_text='<em style="color: gray;">🔧 Auto</em>'
)
# Boolean mode with custom text
boolean_field = TernaryNoneBooleanField(
'Boolean Field',
default=True,
boolean_mode=True,
yes_text="Enabled",
no_text="Disabled"
)
# FontAwesome example
fontawesome_field = TernaryNoneBooleanField(
'Notifications with FontAwesome',
default=None,
yes_text='<i class="fa fa-bell-slash"></i> Muted',
no_text='<i class="fa fa-bell"></i> Unmuted',
none_text='<i class="fa fa-cogs"></i> System default'
)
def test_custom_text():
"""Test custom text functionality"""
form = TestForm()
print("=== Testing TernaryNoneBooleanField Custom Text ===")
# Test default field
print("\n--- Default Field ---")
default_field = form.default_field
default_html = default_field.widget(default_field)
print(f"Contains 'Yes': {'Yes' in default_html}")
print(f"Contains 'No': {'No' in default_html}")
print(f"Contains 'Default': {'Default' in default_html}")
assert 'Yes' in default_html and 'No' in default_html and 'Default' in default_html
# Test custom text field
print("\n--- Custom Text Field with Emojis ---")
notification_field = form.notification_field
notification_html = notification_field.widget(notification_field)
print(f"Contains '🔕 Muted': {'🔕 Muted' in notification_html}")
print(f"Contains '🔔 Unmuted': {'🔔 Unmuted' in notification_html}")
print(f"Contains '⚙️ System default': {'⚙️ System default' in notification_html}")
print(f"Does NOT contain 'Yes': {'Yes' not in notification_html}")
print(f"Does NOT contain 'No': {'No' not in notification_html}")
assert '🔕 Muted' in notification_html and '🔔 Unmuted' in notification_html
assert 'Yes' not in notification_html and 'No' not in notification_html
# Test HTML styling
print("\n--- HTML Styled Field ---")
styled_field = form.styled_field
styled_html = styled_field.widget(styled_field)
print(f"Contains HTML tags: {'<strong' in styled_html}")
print(f"Contains color styling: {'color: green' in styled_html}")
print(f"Contains emojis: {'' in styled_html and '' in styled_html}")
assert '<strong' in styled_html and 'color: green' in styled_html
# Test boolean mode with custom text
print("\n--- Boolean Field with Custom Text ---")
boolean_field = form.boolean_field
boolean_html = boolean_field.widget(boolean_field)
print(f"Contains 'Enabled': {'Enabled' in boolean_html}")
print(f"Contains 'Disabled': {'Disabled' in boolean_html}")
print(f"Does NOT contain 'System default': {'System default' not in boolean_html}")
print(f"Does NOT contain 'Default': {'Default' not in boolean_html}")
assert 'Enabled' in boolean_html and 'Disabled' in boolean_html
assert 'System default' not in boolean_html and 'Default' not in boolean_html
# Test FontAwesome field
print("\n--- FontAwesome Icons Field ---")
fontawesome_field = form.fontawesome_field
fontawesome_html = fontawesome_field.widget(fontawesome_field)
print(f"Contains FontAwesome classes: {'fa fa-bell' in fontawesome_html}")
print(f"Contains multiple FA icons: {'fa fa-bell-slash' in fontawesome_html and 'fa fa-cogs' in fontawesome_html}")
assert 'fa fa-bell' in fontawesome_html
print("\n✅ All custom text tests passed!")
print("\n--- Example Usage ---")
print("TernaryNoneBooleanField('Status', yes_text='🟢 Online', no_text='🔴 Offline', none_text='🟡 Auto')")
print("TernaryNoneBooleanField('Notifications', yes_text='<i class=\"fa fa-bell-slash\"></i> Muted', ...)")
def test_data_processing():
"""Test that custom text doesn't affect data processing"""
print("\n=== Testing Data Processing ===")
form = TestForm()
field = form.notification_field
# Test form data processing
field.process_formdata(['true'])
assert field.data is True, "Custom text should not affect data processing"
print("✅ True processing works with custom text")
field.process_formdata(['false'])
assert field.data is False, "Custom text should not affect data processing"
print("✅ False processing works with custom text")
field.process_formdata(['none'])
assert field.data is None, "Custom text should not affect data processing"
print("✅ None processing works with custom text")
print("✅ All data processing tests passed!")
if __name__ == '__main__':
test_custom_text()
test_data_processing()
+1
View File
@@ -84,6 +84,7 @@ services:
# Comment out ports: when using behind a reverse proxy , enable networks: etc. # Comment out ports: when using behind a reverse proxy , enable networks: etc.
# Mac users! Use "127.0.0.1:5050:5000" (port 5050) so theres no conflict with Airplay etc. (https://github.com/dgtlmoon/changedetection.io/issues/3401)
ports: ports:
- 127.0.0.1:5000:5000 - 127.0.0.1:5000:5000
restart: unless-stopped restart: unless-stopped
+161 -104
View File
@@ -1,4 +1,4 @@
openapi: 3.0.3 openapi: 3.0.4
info: info:
title: ChangeDetection.io API title: ChangeDetection.io API
description: | description: |
@@ -6,7 +6,7 @@ info:
REST API for managing Page watches, Group tags, and Notifications. REST API for managing Page watches, Group tags, and Notifications.
changedetection.io can be driven by its built in simple API, in the examples below you will also find `curl` command line examples to help you. changedetection.io can be driven by its built in simple API, in the examples below you will also find `curl` command line and `python` examples to help you get started faster.
## Where to find my API key? ## Where to find my API key?
@@ -28,7 +28,7 @@ info:
For example: `x-api-key: YOUR_API_KEY` For example: `x-api-key: YOUR_API_KEY`
version: 1.0.0 version: 0.1.0
contact: contact:
name: ChangeDetection.io name: ChangeDetection.io
url: https://github.com/dgtlmoon/changedetection.io url: https://github.com/dgtlmoon/changedetection.io
@@ -89,6 +89,8 @@ tags:
Configure global notification endpoints that can be used across all your watches. Supports various Configure global notification endpoints that can be used across all your watches. Supports various
notification services including email, Discord, Slack, webhooks, and many other popular platforms. notification services including email, Discord, Slack, webhooks, and many other popular platforms.
These settings serve as defaults that can be overridden at the individual watch or tag level. These settings serve as defaults that can be overridden at the individual watch or tag level.
The notification syntax uses [https://github.com/caronc/apprise](https://github.com/caronc/apprise).
- name: Search - name: Search
description: | description: |
@@ -117,14 +119,9 @@ components:
Enter your API key in the "Authorize" button above to automatically populate all code examples. Enter your API key in the "Authorize" button above to automatically populate all code examples.
schemas: schemas:
Watch: WatchBase:
type: object type: object
properties: properties:
uuid:
type: string
format: uuid
description: Unique identifier for the watch
readOnly: true
url: url:
type: string type: string
format: uri format: uri
@@ -132,11 +129,11 @@ components:
maxLength: 5000 maxLength: 5000
title: title:
type: string type: string
description: Custom title for the watch description: Custom title for the web page change monitor (watch)
maxLength: 5000 maxLength: 5000
tag: tag:
type: string type: string
description: Tag UUID to associate with this watch description: Tag UUID to associate with this web page change monitor (watch)
maxLength: 5000 maxLength: 5000
tags: tags:
type: array type: array
@@ -145,7 +142,7 @@ components:
description: Array of tag UUIDs description: Array of tag UUIDs
paused: paused:
type: boolean type: boolean
description: Whether the watch is paused description: Whether the web page change monitor (watch) is paused
muted: muted:
type: boolean type: boolean
description: Whether notifications are muted description: Whether notifications are muted
@@ -191,11 +188,15 @@ components:
seconds: seconds:
type: integer type: integer
description: Time intervals between checks description: Time intervals between checks
time_between_check_use_default:
type: boolean
default: true
description: Whether to use global settings for time between checks - defaults to true if not set
notification_urls: notification_urls:
type: array type: array
items: items:
type: string type: string
description: Notification URLs for this watch description: Notification URLs for this web page change monitor (watch)
notification_title: notification_title:
type: string type: string
description: Custom notification title description: Custom notification title
@@ -227,28 +228,51 @@ components:
maxLength: 5000 maxLength: 5000
required: [operation, selector, optional_value] required: [operation, selector, optional_value]
description: Browser automation steps description: Browser automation steps
last_checked:
type: integer Watch:
description: Unix timestamp of last check allOf:
readOnly: true - $ref: '#/components/schemas/WatchBase'
last_changed: - type: object
type: integer properties:
description: Unix timestamp of last change uuid:
readOnly: true type: string
last_error: format: uuid
type: string description: Unique identifier for the web page change monitor (watch)
description: Last error message readOnly: true
readOnly: true last_checked:
required: type: integer
- url description: Unix timestamp of last check
readOnly: true
last_changed:
type: integer
description: Unix timestamp of last change
readOnly: true
last_error:
type: string
description: Last error message
readOnly: true
last_viewed:
type: integer
description: Unix timestamp in seconds of the last time the watch was viewed. Setting it to a value higher than `last_changed` in the "Update watch" endpoint marks the watch as viewed.
minimum: 0
CreateWatch: CreateWatch:
allOf: allOf:
- $ref: '#/components/schemas/Watch' - $ref: '#/components/schemas/WatchBase'
- type: object - type: 'object'
required: required:
- url - url
UpdateWatch:
allOf:
- $ref: '#/components/schemas/WatchBase'
- type: object
properties:
last_viewed:
type: integer
description: Unix timestamp in seconds of the last time the watch was viewed. Setting it to a value higher than `last_changed` in the "Update watch" endpoint marks the watch as viewed.
minimum: 0
Tag: Tag:
type: object type: object
properties: properties:
@@ -265,12 +289,17 @@ components:
type: array type: array
items: items:
type: string type: string
description: Default notification URLs for watches with this tag description: Default notification URLs for web page change monitors (watches) with this tag
notification_muted: notification_muted:
type: boolean type: boolean
description: Whether notifications are muted for this tag description: Whether notifications are muted for this tag
required:
- title CreateTag:
allOf:
- $ref: '#/components/schemas/Tag'
- type: object
required:
- title
NotificationUrls: NotificationUrls:
type: object type: object
@@ -289,7 +318,7 @@ components:
properties: properties:
watch_count: watch_count:
type: integer type: integer
description: Total number of watches description: Total number of web page change monitors (watches)
tag_count: tag_count:
type: integer type: integer
description: Total number of tags description: Total number of tags
@@ -307,7 +336,7 @@ components:
type: object type: object
additionalProperties: additionalProperties:
$ref: '#/components/schemas/Watch' $ref: '#/components/schemas/Watch'
description: Dictionary of matching watches keyed by UUID description: Dictionary of matching web page change monitors (watches) keyed by UUID
WatchHistory: WatchHistory:
type: object type: object
@@ -326,9 +355,10 @@ components:
paths: paths:
/watch: /watch:
get: get:
operationId: listWatches
tags: [Watch Management] tags: [Watch Management]
summary: List all watches summary: List all watches
description: Return concise list of available watches and basic info description: Return concise list of available web page change monitors (watches) and basic info
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -388,9 +418,10 @@ paths:
last_checked: 1640998800 last_checked: 1640998800
last_changed: 1640995200 last_changed: 1640995200
post: post:
operationId: createWatch
tags: [Watch Management] tags: [Watch Management]
summary: Create a new watch summary: Create a new watch
description: Create a single watch. Requires at least 'url' to be set. description: Create a single web page change monitor (watch). Requires at least 'url' to be set.
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -436,7 +467,7 @@ paths:
hours: 1 hours: 1
responses: responses:
'200': '200':
description: Watch created successfully description: Web page change monitor (watch) created successfully
content: content:
text/plain: text/plain:
schema: schema:
@@ -451,10 +482,10 @@ paths:
/watch/{uuid}: /watch/{uuid}:
get: get:
operationId: getSingleWatch operationId: getWatch
tags: [Watch Management] tags: [Watch Management]
summary: Get single watch summary: Get single watch
description: Retrieve watch information and set muted/paused status. Returns the FULL Watch JSON. description: Retrieve web page change monitor (watch) information and set muted/paused status. Returns the FULL Watch JSON.
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -472,13 +503,13 @@ paths:
- name: uuid - name: uuid
in: path in: path
required: true required: true
description: Watch unique ID description: Web page change monitor (watch) unique ID
schema: schema:
type: string type: string
format: uuid format: uuid
- name: recheck - name: recheck
in: query in: query
description: Recheck this watch description: Recheck this web page change monitor (watch)
schema: schema:
type: string type: string
enum: ["1", "true"] enum: ["1", "true"]
@@ -506,7 +537,7 @@ paths:
type: string type: string
example: "OK" example: "OK"
'404': '404':
description: Watch not found description: Web page change monitor (watch) not found
content: content:
application/json: application/json:
schema: schema:
@@ -516,7 +547,7 @@ paths:
operationId: updateWatch operationId: updateWatch
tags: [Watch Management] tags: [Watch Management]
summary: Update watch summary: Update watch
description: Update an existing watch using JSON. Accepts the same structure as returned in [get single watch information](#operation/getSingleWatch). description: Update an existing web page change monitor (watch) using JSON. Accepts the same structure as returned in [get single watch information](#operation/getWatch).
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -549,7 +580,7 @@ paths:
- name: uuid - name: uuid
in: path in: path
required: true required: true
description: Watch unique ID description: Web page change monitor (watch) unique ID
schema: schema:
type: string type: string
format: uuid format: uuid
@@ -558,10 +589,10 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Watch' $ref: '#/components/schemas/UpdateWatch'
responses: responses:
'200': '200':
description: Watch updated successfully description: Web page change monitor (watch) updated successfully
content: content:
text/plain: text/plain:
schema: schema:
@@ -571,9 +602,10 @@ paths:
description: Server error description: Server error
delete: delete:
tags: [Watch Management] operationId: deleteWatch
tags: [Watch Management]
summary: Delete watch summary: Delete watch
description: Delete a watch and all related history description: Delete a web page change monitor (watch) and all related history
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -591,13 +623,13 @@ paths:
- name: uuid - name: uuid
in: path in: path
required: true required: true
description: Watch unique ID description: Web page change monitor (watch) unique ID
schema: schema:
type: string type: string
format: uuid format: uuid
responses: responses:
'200': '200':
description: Watch deleted successfully description: Web page change monitor (watch) deleted successfully
content: content:
text/plain: text/plain:
schema: schema:
@@ -606,9 +638,10 @@ paths:
/watch/{uuid}/history: /watch/{uuid}/history:
get: get:
operationId: getWatchHistory
tags: [Watch History] tags: [Watch History]
summary: Get watch history summary: Get watch history
description: Get a list of all historical snapshots available for a watch description: Get a list of all historical snapshots available for a web page change monitor (watch)
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -626,7 +659,7 @@ paths:
- name: uuid - name: uuid
in: path in: path
required: true required: true
description: Watch unique ID description: Web page change monitor (watch) unique ID
schema: schema:
type: string type: string
format: uuid format: uuid
@@ -641,13 +674,14 @@ paths:
"1640995200": "/path/to/snapshot1.txt" "1640995200": "/path/to/snapshot1.txt"
"1640998800": "/path/to/snapshot2.txt" "1640998800": "/path/to/snapshot2.txt"
'404': '404':
description: Watch not found description: Web page change monitor (watch) not found
/watch/{uuid}/history/{timestamp}: /watch/{uuid}/history/{timestamp}:
get: get:
operationId: getWatchSnapshot
tags: [Snapshots] tags: [Snapshots]
summary: Get single snapshot summary: Get single snapshot
description: Get single snapshot from watch. Use 'latest' for the most recent snapshot. description: Get single snapshot from web page change monitor (watch). Use 'latest' for the most recent snapshot.
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -666,7 +700,7 @@ paths:
- name: uuid - name: uuid
in: path in: path
required: true required: true
description: Watch unique ID description: Web page change monitor (watch) unique ID
schema: schema:
type: string type: string
format: uuid format: uuid
@@ -697,9 +731,10 @@ paths:
/watch/{uuid}/favicon: /watch/{uuid}/favicon:
get: get:
operationId: getWatchFavicon
tags: [Favicon] tags: [Favicon]
summary: Get watch favicon summary: Get watch favicon
description: Get the favicon for a watch as displayed in the watch overview list. description: Get the favicon for a web page change monitor (watch) as displayed in the watch overview list.
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -719,7 +754,7 @@ paths:
- name: uuid - name: uuid
in: path in: path
required: true required: true
description: Watch unique ID description: Web page change monitor (watch) unique ID
schema: schema:
type: string type: string
format: uuid format: uuid
@@ -736,6 +771,7 @@ paths:
/tags: /tags:
get: get:
operationId: listTags
tags: [Group / Tag Management] tags: [Group / Tag Management]
summary: List all tags summary: List all tags
description: Return list of available tags/groups description: Return list of available tags/groups
@@ -772,11 +808,62 @@ paths:
notification_urls: ["discord://webhook_id/webhook_token"] notification_urls: ["discord://webhook_id/webhook_token"]
notification_muted: false notification_muted: false
/tag:
post:
operationId: createTag
tags: [Group / Tag Management]
summary: Create tag
description: Create a single tag/group
x-code-samples:
- lang: 'curl'
source: |
curl -X POST "http://localhost:5000/api/v1/tag" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Important Sites"
}'
- lang: 'Python'
source: |
import requests
headers = {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
}
data = {'title': 'Important Sites'}
response = requests.post('http://localhost:5000/api/v1/tag',
headers=headers, json=data)
print(response.json())
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateTag'
example:
title: "Important Sites"
responses:
'201':
description: Tag created successfully
content:
application/json:
schema:
type: object
properties:
uuid:
type: string
format: uuid
description: UUID of the created tag
'400':
description: Invalid or unsupported tag
/tag/{uuid}: /tag/{uuid}:
get: get:
operationId: getTag
tags: [Group / Tag Management] tags: [Group / Tag Management]
summary: Get single tag summary: Get single tag
description: Retrieve tag information, set notification_muted status, recheck all in tag. description: Retrieve tag information, set notification_muted status, recheck all web page change monitors (watches) in tag.
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -806,7 +893,7 @@ paths:
enum: [muted, unmuted] enum: [muted, unmuted]
- name: recheck - name: recheck
in: query in: query
description: Queue all watches with this tag for recheck description: Queue all web page change monitors (watches) with this tag for recheck
schema: schema:
type: string type: string
enum: ["true"] enum: ["true"]
@@ -825,6 +912,7 @@ paths:
description: Tag not found description: Tag not found
put: put:
operationId: updateTag
tags: [Group / Tag Management] tags: [Group / Tag Management]
summary: Update tag summary: Update tag
description: Update an existing tag using JSON description: Update an existing tag using JSON
@@ -875,9 +963,10 @@ paths:
description: Server error description: Server error
delete: delete:
operationId: deleteTag
tags: [Group / Tag Management] tags: [Group / Tag Management]
summary: Delete tag summary: Delete tag
description: Delete a tag/group and remove it from all watches description: Delete a tag/group and remove it from all web page change monitors (watches)
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -903,48 +992,10 @@ paths:
'200': '200':
description: Tag deleted successfully description: Tag deleted successfully
post:
tags: [Group / Tag Management]
summary: Create tag
description: Create a single tag/group
x-code-samples:
- lang: 'curl'
source: |
curl -X POST "http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Important Sites"
}'
- lang: 'Python'
source: |
import requests
headers = {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
}
tag_uuid = '550e8400-e29b-41d4-a716-446655440000'
data = {'title': 'Important Sites'}
response = requests.post(f'http://localhost:5000/api/v1/tag/{tag_uuid}',
headers=headers, json=data)
print(response.text)
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Tag'
example:
title: "Important Sites"
responses:
'200':
description: Tag created successfully
'500':
description: Server error
/notifications: /notifications:
get: get:
operationId: getNotifications
tags: [Notifications] tags: [Notifications]
summary: Get notification URLs summary: Get notification URLs
description: Return the notification URL list from the configuration description: Return the notification URL list from the configuration
@@ -969,6 +1020,7 @@ paths:
$ref: '#/components/schemas/NotificationUrls' $ref: '#/components/schemas/NotificationUrls'
post: post:
operationId: addNotifications
tags: [Notifications] tags: [Notifications]
summary: Add notification URLs summary: Add notification URLs
description: Add one or more notification URLs to the configuration description: Add one or more notification URLs to the configuration
@@ -1022,6 +1074,7 @@ paths:
description: Invalid input description: Invalid input
put: put:
operationId: replaceNotifications
tags: [Notifications] tags: [Notifications]
summary: Replace notification URLs summary: Replace notification URLs
description: Replace all notification URLs with the provided list (can be empty) description: Replace all notification URLs with the provided list (can be empty)
@@ -1069,6 +1122,7 @@ paths:
description: Invalid input description: Invalid input
delete: delete:
operationId: deleteNotifications
tags: [Notifications] tags: [Notifications]
summary: Delete notification URLs summary: Delete notification URLs
description: Delete one or more notification URLs from the configuration description: Delete one or more notification URLs from the configuration
@@ -1113,9 +1167,10 @@ paths:
/search: /search:
get: get:
operationId: searchWatches
tags: [Search] tags: [Search]
summary: Search watches summary: Search watches
description: Search watches by URL or title text description: Search web page change monitors (watches) by URL or title text
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -1167,6 +1222,7 @@ paths:
/import: /import:
post: post:
operationId: importWatches
tags: [Import] tags: [Import]
summary: Import watch URLs summary: Import watch URLs
description: Import a list of URLs to monitor. Accepts line-separated URLs in request body. description: Import a list of URLs to monitor. Accepts line-separated URLs in request body.
@@ -1192,17 +1248,17 @@ paths:
parameters: parameters:
- name: tag_uuids - name: tag_uuids
in: query in: query
description: Tag UUID to apply to imported watches description: Tag UUID to apply to imported web page change monitors (watches)
schema: schema:
type: string type: string
- name: tag - name: tag
in: query in: query
description: Tag name to apply to imported watches description: Tag name to apply to imported web page change monitors (watches)
schema: schema:
type: string type: string
- name: proxy - name: proxy
in: query in: query
description: Proxy key to use for imported watches description: Proxy key to use for imported web page change monitors (watches)
schema: schema:
type: string type: string
- name: dedupe - name: dedupe
@@ -1237,6 +1293,7 @@ paths:
/systeminfo: /systeminfo:
get: get:
operationId: getSystemInfo
tags: [System Information] tags: [System Information]
summary: Get system information summary: Get system information
description: Return information about the current system state description: Return information about the current system state
@@ -1263,4 +1320,4 @@ paths:
watch_count: 42 watch_count: 42
tag_count: 5 tag_count: 5
uptime: "2 days, 3:45:12" uptime: "2 days, 3:45:12"
version: "0.50.10" version: "0.50.10"
+111 -107
View File
File diff suppressed because one or more lines are too long
+9 -3
View File
@@ -41,13 +41,16 @@ jsonpath-ng~=1.5.3
# Notification library # Notification library
apprise==1.9.3 apprise==1.9.3
# - Needed for apprise/spush, and maybe others? hopefully doesnt trigger a rust compile.
# - Requires extra wheel for rPi, adds build time for arm/v8 which is not in piwheels
# Pinned to 43.0.1 for ARM compatibility (45.x may not have pre-built ARM wheels)
# Also pinned because dependabot wants specific versions
cryptography==44.0.1
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 # 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 # use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814
paho-mqtt!=2.0.* paho-mqtt!=2.0.*
# Requires extra wheel for rPi
#cryptography~=42.0.8
# Used for CSS filtering # Used for CSS filtering
beautifulsoup4>=4.0.0 beautifulsoup4>=4.0.0
@@ -89,6 +92,9 @@ pytest-flask ~=1.2
# Anything 4.0 and up but not 5.0 # Anything 4.0 and up but not 5.0
jsonschema ~= 4.0 jsonschema ~= 4.0
# OpenAPI validation support
openapi-core[flask] >= 0.19.0
loguru loguru