Compare commits

...

22 Commits

Author SHA1 Message Date
dgtlmoon
07f98d6bd3 0.50.29
Some checks failed
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-10-23 20:58:28 +02:00
dgtlmoon
f71550da4d Discord + Telegram - Adding better styling (Discord now uses strike-through and bold for removal/additions instead of broken HTML) (#3528) 2025-10-23 20:57:59 +02:00
dgtlmoon
8c3d0d7e31 Notifications - Refactor/cleanup notification handling and rename 'Markdown' to "Markdown to HTML" to make more sense. (#3527) Re #3526 -
Some checks failed
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-10-23 13:55:20 +02:00
dgtlmoon
46658a85d6 UI - Fix watch table striping on delete #3523 2025-10-23 13:18:16 +02:00
dgtlmoon
d699652955 Update flask requirement from ~=2.3 to ~=3.1, unpin werkzeug (#3502)
Some checks failed
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
2025-10-23 10:35:56 +02:00
dependabot[bot]
9e88db5d9b Bump elementpath from 4.1.5 to 5.0.4 (#3470) 2025-10-23 10:34:32 +02:00
dependabot[bot]
5d9c102aff Update beautifulsoup4 requirement (#3471) 2025-10-23 10:34:24 +02:00
dependabot[bot]
cb1c36d97d Update validators requirement from ~=0.21 to ~=0.35 (#3500) 2025-10-23 10:33:30 +02:00
dgtlmoon
cc29ba5ea9 0.50.28
Some checks failed
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-10-21 21:50:49 +02:00
dgtlmoon
6f371b1bc6 Email notification format fixes (#3525) 2025-10-21 21:34:17 +02:00
dgtlmoon
785dabd071 Empty "ignore text" lines could break ignore text and prevent changes from being detected (#3524)
Some checks failed
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-10-21 15:54:13 +02:00
dgtlmoon
09914d54a0 0.50.27
Some checks failed
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-10-19 22:30:19 +02:00
ReggX
58b5586674 Fix error handling for first empty filter response (#3516) 2025-10-19 22:28:06 +02:00
dgtlmoon
cb02ccc8b4 0.50.26
Some checks failed
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 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-10-17 14:08:52 +02:00
dgtlmoon
ec692ed727 pip build - Improving fix for #3509, Adding automated test for #3509 2025-10-17 12:48:50 +02:00
dgtlmoon
2fb2ea573e 0.50.25
Some checks failed
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 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-10-16 16:59:27 +02:00
dgtlmoon
ada2dc6112 pip build - Be sure to include API spec (#3511) 2025-10-16 16:35:24 +02:00
dgtlmoon
ad9024a4f0 Improved watch delete (#3510) 2025-10-16 16:35:06 +02:00
dgtlmoon
047c10e23c Notification service improved failure alerts for filter missing + browsersteps problems (#3507) 2025-10-16 14:30:50 +02:00
dgtlmoon
4f83164544 Notifications - Small fix for notification format handling, enabling HTML Color for {{diff_removed}} and {{diff_added}} (#3508) 2025-10-16 13:13:15 +02:00
dgtlmoon
6f926ed595 0.50.24
Some checks failed
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-10-14 11:03:17 +02:00
dgtlmoon
249dc55212 Notification - Make sure all notification tokens have something set even for form validation, fixes hassio:// with {{ watch_uuid }} in notification URL form (#3504) 2025-10-14 10:58:53 +02:00
31 changed files with 1174 additions and 258 deletions

View File

@@ -28,7 +28,7 @@ jobs:
test-pypi-package: test-pypi-package:
name: Test the built 📦 package works basically. name: Test the built package works basically.
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- build - build
@@ -42,18 +42,39 @@ jobs:
uses: actions/setup-python@v6 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
run: | run: |
set -ex set -ex
ls -alR ls -alR
# Find and install the first .whl file # Install the first wheel found in dist/
find dist -type f -name "*.whl" -exec pip3 install {} \; -quit WHEEL=$(find dist -type f -name "*.whl" -print -quit)
echo Installing $WHEEL
python3 -m pip install --upgrade pip
python3 -m pip install "$WHEEL"
changedetection.io -d /tmp -p 10000 & changedetection.io -d /tmp -p 10000 &
sleep 3 sleep 3
curl --retry-connrefused --retry 6 http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null curl --retry-connrefused --retry 6 http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null
curl --retry-connrefused --retry 6 http://127.0.0.1:10000/ >/dev/null curl --retry-connrefused --retry 6 http://127.0.0.1:10000/ >/dev/null
# --- API test ---
# This also means that the docs/api-spec.yml was shipped and could be read
test -f /tmp/url-watches.json
API_KEY=$(jq -r '.. | .api_access_token? // empty' /tmp/url-watches.json)
echo Test API KEY is $API_KEY
curl -X POST "http://127.0.0.1:10000/api/v1/watch" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
--show-error --fail \
--retry 6 --retry-delay 1 --retry-connrefused \
-d '{
"url": "https://example.com",
"title": "Example Site Monitor",
"time_between_check": { "hours": 1 }
}'
killall changedetection.io killall changedetection.io

View File

@@ -54,7 +54,10 @@ jobs:
- name: Spin up ancillary SMTP+Echo message test server - name: Spin up ancillary SMTP+Echo message test server
run: | run: |
# Debug SMTP server/echo message back server # Debug SMTP server/echo message back server, telnet 11080 to it should immediately bounce back the most recent message that tried to send (then you can see if cdio tried to send, the format, etc)
# 11025 is the SMTP port for testing
# apprise example would be 'mailto://changedetection@localhost:11025/?to=fff@home.com (it will also echo to STDOUT)
# telnet localhost 11080
docker run --network changedet-network -d -p 11025:11025 -p 11080:11080 --hostname mailserver test-changedetectionio bash -c 'pip3 install aiosmtpd && python changedetectionio/tests/smtp/smtp-test-server.py' docker run --network changedet-network -d -p 11025:11025 -p 11080:11080 --hostname mailserver test-changedetectionio bash -c 'pip3 install aiosmtpd && python changedetectionio/tests/smtp/smtp-test-server.py'
docker ps docker ps

View File

@@ -1,4 +1,5 @@
recursive-include changedetectionio/api * recursive-include changedetectionio/api *
include docs/api-spec.yaml
recursive-include changedetectionio/blueprint * recursive-include changedetectionio/blueprint *
recursive-include changedetectionio/conditions * recursive-include changedetectionio/conditions *
recursive-include changedetectionio/content_fetchers * recursive-include changedetectionio/content_fetchers *

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.23' __version__ = '0.50.29'
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError

View File

@@ -37,6 +37,10 @@ def get_openapi_spec():
from openapi_core import OpenAPI # Lazy import - saves ~10.7 MB on startup from openapi_core import OpenAPI # Lazy import - saves ~10.7 MB on startup
spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml') spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml')
if not os.path.exists(spec_path):
# Possibly for pip3 packages
spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml')
with open(spec_path, 'r') as f: with open(spec_path, 'r') as f:
spec_dict = yaml.safe_load(f) spec_dict = yaml.safe_load(f)
_openapi_spec = OpenAPI.from_dict(spec_dict) _openapi_spec = OpenAPI.from_dict(spec_dict)

View File

@@ -1,5 +1,6 @@
from changedetectionio.jinja2_custom import render as jinja_render from changedetectionio.jinja2_custom import render as jinja_render
from changedetectionio.notification.handler import apply_service_tweaks
from changedetectionio.store import ChangeDetectionStore from changedetectionio.store import ChangeDetectionStore
from feedgen.feed import FeedGenerator from feedgen.feed import FeedGenerator
from flask import Blueprint, make_response, request, url_for, redirect from flask import Blueprint, make_response, request, url_for, redirect
@@ -120,9 +121,13 @@ def construct_blueprint(datastore: ChangeDetectionStore):
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]),
newest_version_file_contents=watch.get_history_snapshot(dates[-1]), newest_version_file_contents=watch.get_history_snapshot(dates[-1]),
include_equal=False, include_equal=False,
line_feed_sep="<br>", line_feed_sep="<br>"
html_colour=html_colour_enable
) )
requested_output_format = 'htmlcolor' if html_colour_enable else 'html'
html_diff = apply_service_tweaks(url='', n_body=html_diff, n_title=None, requested_output_format=requested_output_format)
except FileNotFoundError as e: except FileNotFoundError as e:
html_diff = f"History snapshot file for watch {watch.get('uuid')}@{watch.last_changed} - '{watch.get('title')} not found." html_diff = f"History snapshot file for watch {watch.get('uuid')}@{watch.last_changed} - '{watch.get('title')} not found."

View File

@@ -2,6 +2,7 @@ from flask import Blueprint, request, make_response
import random import random
from loguru import logger from loguru import logger
from changedetectionio.notification_service import NotificationContextData
from changedetectionio.store import ChangeDetectionStore from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required from changedetectionio.auth_decorator import login_optionally_required
@@ -19,6 +20,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
import apprise import apprise
from changedetectionio.notification.handler import process_notification from changedetectionio.notification.handler import process_notification
from changedetectionio.notification.apprise_plugin.assets import apprise_asset from changedetectionio.notification.apprise_plugin.assets import apprise_asset
from changedetectionio.jinja2_custom import render as jinja_render
from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler
@@ -61,16 +63,20 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return 'Error: No Notification URLs set/found' return 'Error: No Notification URLs set/found'
for n_url in notification_urls: for n_url in notification_urls:
# We are ONLY validating the apprise:// part here, convert all tags to something so as not to break apprise URLs
generic_notification_context_data = NotificationContextData()
generic_notification_context_data.set_random_for_validation()
n_url = jinja_render(template_str=n_url, **generic_notification_context_data).strip()
if len(n_url.strip()): if len(n_url.strip()):
if not apobj.add(n_url): if not apobj.add(n_url):
return f'Error: {n_url} is not a valid AppRise URL.' return f'Error: {n_url} is not a valid AppRise URL.'
try: try:
# use the same as when it is triggered, but then override it with the form test values # use the same as when it is triggered, but then override it with the form test values
n_object = { n_object = NotificationContextData({
'watch_url': request.form.get('window_url', "https://changedetection.io"), 'watch_url': request.form.get('window_url', "https://changedetection.io"),
'notification_urls': notification_urls 'notification_urls': notification_urls
} })
# Only use if present, if not set in n_object it should use the default system value # Only use if present, if not set in n_object it should use the default system value
if 'notification_format' in request.form and request.form['notification_format'].strip(): if 'notification_format' in request.form and request.form['notification_format'].strip():

View File

@@ -1,8 +1,21 @@
import difflib import difflib
from typing import List, Iterator, Union from typing import List, Iterator, Union
REMOVED_STYLE = "background-color: #fadad7; color: #b30000;" HTML_REMOVED_STYLE = "background-color: #fadad7; color: #b30000;"
ADDED_STYLE = "background-color: #eaf2c2; color: #406619;" HTML_ADDED_STYLE = "background-color: #eaf2c2; color: #406619;"
# These get set to html or telegram type or discord compatible or whatever in handler.py
REMOVED_PLACEMARKER_OPEN = '<<<removed_PLACEMARKER_OPEN'
REMOVED_PLACEMARKER_CLOSED = '<<<removed_PLACEMARKER_CLOSED'
ADDED_PLACEMARKER_OPEN = '<<<added_PLACEMARKER_OPEN'
ADDED_PLACEMARKER_CLOSED = '<<<added_PLACEMARKER_CLOSED'
CHANGED_PLACEMARKER_OPEN = '<<<changed_PLACEMARKER_OPEN'
CHANGED_PLACEMARKER_CLOSED = '<<<changed_PLACEMARKER_CLOSED'
CHANGED_INTO_PLACEMARKER_OPEN = '<<<changed_into_PLACEMARKER_OPEN'
CHANGED_INTO_PLACEMARKER_CLOSED = '<<<changed_into_PLACEMARKER_CLOSED'
def same_slicer(lst: List[str], start: int, end: int) -> List[str]: def same_slicer(lst: List[str], start: int, end: int) -> List[str]:
"""Return a slice of the list, or a single element if start == end.""" """Return a slice of the list, or a single element if start == end."""
@@ -15,8 +28,7 @@ def customSequenceMatcher(
include_removed: bool = True, include_removed: bool = True,
include_added: bool = True, include_added: bool = True,
include_replaced: bool = True, include_replaced: bool = True,
include_change_type_prefix: bool = True, include_change_type_prefix: bool = True
html_colour: bool = False
) -> Iterator[List[str]]: ) -> Iterator[List[str]]:
""" """
Compare two sequences and yield differences based on specified parameters. Compare two sequences and yield differences based on specified parameters.
@@ -29,8 +41,6 @@ def customSequenceMatcher(
include_added (bool): Include added parts include_added (bool): Include added parts
include_replaced (bool): Include replaced parts include_replaced (bool): Include replaced parts
include_change_type_prefix (bool): Add prefixes to indicate change types include_change_type_prefix (bool): Add prefixes to indicate change types
html_colour (bool): Use HTML background colors for differences
Yields: Yields:
List[str]: Differences between sequences List[str]: Differences between sequences
""" """
@@ -42,22 +52,22 @@ def customSequenceMatcher(
if include_equal and tag == 'equal': if include_equal and tag == 'equal':
yield before[alo:ahi] yield before[alo:ahi]
elif include_removed and tag == 'delete': elif include_removed and tag == 'delete':
if html_colour: if include_change_type_prefix:
yield [f'<span style="{REMOVED_STYLE}">{line}</span>' for line in same_slicer(before, alo, ahi)] yield [f'{REMOVED_PLACEMARKER_OPEN}{line}{REMOVED_PLACEMARKER_CLOSED}' for line in same_slicer(before, alo, ahi)]
else: else:
yield [f"(removed) {line}" for line in same_slicer(before, alo, ahi)] if include_change_type_prefix else same_slicer(before, alo, ahi) yield same_slicer(before, alo, ahi)
elif include_replaced and tag == 'replace': elif include_replaced and tag == 'replace':
if html_colour: if include_change_type_prefix:
yield [f'<span style="{REMOVED_STYLE}">{line}</span>' for line in same_slicer(before, alo, ahi)] + \ yield [f'{CHANGED_PLACEMARKER_OPEN}{line}{CHANGED_PLACEMARKER_CLOSED}' for line in same_slicer(before, alo, ahi)] + \
[f'<span style="{ADDED_STYLE}">{line}</span>' for line in same_slicer(after, blo, bhi)] [f'{CHANGED_INTO_PLACEMARKER_OPEN}{line}{CHANGED_INTO_PLACEMARKER_CLOSED}' for line in same_slicer(after, blo, bhi)]
else: else:
yield [f"(changed) {line}" for line in same_slicer(before, alo, ahi)] + \ yield same_slicer(before, alo, ahi) + same_slicer(after, blo, bhi)
[f"(into) {line}" for line in same_slicer(after, blo, bhi)] if include_change_type_prefix else same_slicer(before, alo, ahi) + same_slicer(after, blo, bhi)
elif include_added and tag == 'insert': elif include_added and tag == 'insert':
if html_colour: if include_change_type_prefix:
yield [f'<span style="{ADDED_STYLE}">{line}</span>' for line in same_slicer(after, blo, bhi)] yield [f'{ADDED_PLACEMARKER_OPEN}{line}{ADDED_PLACEMARKER_CLOSED}' for line in same_slicer(after, blo, bhi)]
else: else:
yield [f"(added) {line}" for line in same_slicer(after, blo, bhi)] if include_change_type_prefix else same_slicer(after, blo, bhi) yield same_slicer(after, blo, bhi)
def render_diff( def render_diff(
previous_version_file_contents: str, previous_version_file_contents: str,
@@ -68,8 +78,7 @@ def render_diff(
include_replaced: bool = True, include_replaced: bool = True,
line_feed_sep: str = "\n", line_feed_sep: str = "\n",
include_change_type_prefix: bool = True, include_change_type_prefix: bool = True,
patch_format: bool = False, patch_format: bool = False
html_colour: bool = False
) -> str: ) -> str:
""" """
Render the difference between two file contents. Render the difference between two file contents.
@@ -84,8 +93,6 @@ def render_diff(
line_feed_sep (str): Separator for lines in output line_feed_sep (str): Separator for lines in output
include_change_type_prefix (bool): Add prefixes to indicate change types include_change_type_prefix (bool): Add prefixes to indicate change types
patch_format (bool): Use patch format for output patch_format (bool): Use patch format for output
html_colour (bool): Use HTML background colors for differences
Returns: Returns:
str: Rendered difference str: Rendered difference
""" """
@@ -103,8 +110,7 @@ def render_diff(
include_removed=include_removed, include_removed=include_removed,
include_added=include_added, include_added=include_added,
include_replaced=include_replaced, include_replaced=include_replaced,
include_change_type_prefix=include_change_type_prefix, include_change_type_prefix=include_change_type_prefix
html_colour=html_colour
) )
def flatten(lst: List[Union[str, List[str]]]) -> str: def flatten(lst: List[Union[str, List[str]]]) -> str:

View File

@@ -5,6 +5,7 @@ from wtforms.widgets.core import TimeInput
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES
from changedetectionio.conditions.form import ConditionFormRow from changedetectionio.conditions.form import ConditionFormRow
from changedetectionio.notification_service import NotificationContextData
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from wtforms import ( from wtforms import (
@@ -469,11 +470,16 @@ class ValidateAppRiseServers(object):
import apprise import apprise
from .notification.apprise_plugin.assets import apprise_asset from .notification.apprise_plugin.assets import apprise_asset
from .notification.apprise_plugin.custom_handlers import apprise_http_custom_handler # noqa: F401 from .notification.apprise_plugin.custom_handlers import apprise_http_custom_handler # noqa: F401
from changedetectionio.jinja2_custom import render as jinja_render
apobj = apprise.Apprise(asset=apprise_asset) apobj = apprise.Apprise(asset=apprise_asset)
for server_url in field.data: for server_url in field.data:
url = server_url.strip() generic_notification_context_data = NotificationContextData()
# Make sure something is atleast in all those regular token fields
generic_notification_context_data.set_random_for_validation()
url = jinja_render(template_str=server_url.strip(), **generic_notification_context_data).strip()
if url.startswith("#"): if url.startswith("#"):
continue continue
@@ -500,7 +506,7 @@ class ValidateJinja2Template(object):
jinja2_env = create_jinja_env(loader=BaseLoader) jinja2_env = create_jinja_env(loader=BaseLoader)
# Add notification tokens for validation # Add notification tokens for validation
jinja2_env.globals.update(notification.valid_tokens) jinja2_env.globals.update(NotificationContextData())
if hasattr(field, 'extra_notification_tokens'): if hasattr(field, 'extra_notification_tokens'):
jinja2_env.globals.update(field.extra_notification_tokens) jinja2_env.globals.update(field.extra_notification_tokens)
@@ -755,6 +761,14 @@ class commonSettingsForm(Form):
scheduler_timezone_default = StringField("Default timezone for watch check scheduler", render_kw={"list": "timezones"}, validators=[validateTimeZoneName()]) scheduler_timezone_default = StringField("Default timezone for watch check scheduler", render_kw={"list": "timezones"}, validators=[validateTimeZoneName()])
webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")]) webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")])
# Not true anymore but keep the validate_ hook for future use, we convert color tags
# def validate_notification_urls(self, field):
# """Validate that HTML Color format is not used with Telegram"""
# if self.notification_format.data == 'HTML Color' and field.data:
# for url in field.data:
# if url and ('tgram://' in url or 'discord://' in url or 'discord.com/api/webhooks' in url):
# raise ValidationError('HTML Color format is not supported by Telegram and Discord. Please choose another Notification Format (Plain Text, HTML, or Markdown to HTML).')
class importForm(Form): class importForm(Form):
from . import processors from . import processors

View File

@@ -408,6 +408,9 @@ def strip_ignore_text(content, wordlist, mode="content"):
ignored_lines = [] ignored_lines = []
for k in wordlist: for k in wordlist:
# Skip empty strings to avoid matching everything
if not k or not k.strip():
continue
# Is it a regex? # Is it a regex?
res = re.search(PERL_STYLE_REGEX, k, re.IGNORECASE) res = re.search(PERL_STYLE_REGEX, k, re.IGNORECASE)
if res: if res:

View File

@@ -9,6 +9,7 @@ from pathlib import Path
from loguru import logger from loguru import logger
from .. import jinja2_custom as safe_jinja from .. import jinja2_custom as safe_jinja
from ..diff import ADDED_PLACEMARKER_OPEN
from ..html_tools import TRANSLATE_WHITESPACE_TABLE from ..html_tools import TRANSLATE_WHITESPACE_TABLE
# Allowable protocols, protects against javascript: etc # Allowable protocols, protects against javascript: etc
@@ -89,9 +90,8 @@ class model(watch_base):
ready_url = jinja_render(template_str=url) ready_url = jinja_render(template_str=url)
except Exception as e: except Exception as e:
logger.critical(f"Invalid URL template for: '{url}' - {str(e)}") logger.critical(f"Invalid URL template for: '{url}' - {str(e)}")
from flask import ( from flask import flash, url_for
flash, Markup, url_for from markupsafe import Markup
)
message = Markup('<a href="{}#general">The URL {} is invalid and cannot be used, click to edit</a>'.format( message = Markup('<a href="{}#general">The URL {} is invalid and cannot be used, click to edit</a>'.format(
url_for('ui.ui_edit.edit_page', uuid=self.get('uuid')), self.get('url', ''))) url_for('ui.ui_edit.edit_page', uuid=self.get('uuid')), self.get('url', '')))
flash(message, 'error') flash(message, 'error')

View File

@@ -1,6 +1,5 @@
from changedetectionio.model import default_notification_format_for_watch from changedetectionio.model import default_notification_format_for_watch
ult_notification_format_for_watch = 'System default'
default_notification_format = 'HTML Color' default_notification_format = 'HTML Color'
default_notification_body = '{{watch_url}} had a change.\n---\n{{diff}}\n---\n' default_notification_body = '{{watch_url}} had a change.\n---\n{{diff}}\n---\n'
default_notification_title = 'ChangeDetection.io Notification - {{watch_url}}' default_notification_title = 'ChangeDetection.io Notification - {{watch_url}}'
@@ -8,28 +7,11 @@ default_notification_title = 'ChangeDetection.io Notification - {{watch_url}}'
# The values (markdown etc) are from apprise NotifyFormat, # The values (markdown etc) are from apprise NotifyFormat,
# But to avoid importing the whole heavy module just use the same strings here. # But to avoid importing the whole heavy module just use the same strings here.
valid_notification_formats = { valid_notification_formats = {
'Text': 'text', 'Plain Text': 'text',
'Markdown': 'markdown',
'HTML': 'html', 'HTML': 'html',
'HTML Color': 'htmlcolor', 'HTML Color': 'htmlcolor',
'Markdown to HTML': 'markdown',
# Used only for editing a watch (not for global) # Used only for editing a watch (not for global)
default_notification_format_for_watch: default_notification_format_for_watch default_notification_format_for_watch: default_notification_format_for_watch
} }
valid_tokens = {
'base_url': '',
'current_snapshot': '',
'diff': '',
'diff_added': '',
'diff_full': '',
'diff_patch': '',
'diff_removed': '',
'diff_url': '',
'preview_url': '',
'triggered_text': '',
'watch_tag': '',
'watch_title': '',
'watch_url': '',
'watch_uuid': '',
}

View File

@@ -70,6 +70,7 @@ def apprise_http_custom_handler(
title: str, title: str,
notify_type: str, notify_type: str,
meta: dict, meta: dict,
body_format: str = None,
*args, *args,
**kwargs, **kwargs,
) -> bool: ) -> bool:

View File

@@ -0,0 +1,286 @@
"""
Custom Discord plugin for changedetection.io
Extends Apprise's Discord plugin to support custom colored embeds for removed/added content
"""
from apprise.plugins.discord import NotifyDiscord
from apprise.decorators import notify
from apprise.common import NotifyFormat
from loguru import logger
# Import placeholders from changedetection's diff module
from ...diff import (
REMOVED_PLACEMARKER_OPEN,
REMOVED_PLACEMARKER_CLOSED,
ADDED_PLACEMARKER_OPEN,
ADDED_PLACEMARKER_CLOSED,
CHANGED_PLACEMARKER_OPEN,
CHANGED_PLACEMARKER_CLOSED,
CHANGED_INTO_PLACEMARKER_OPEN,
CHANGED_INTO_PLACEMARKER_CLOSED,
)
# Discord embed sidebar colors for different change types
DISCORD_COLOR_UNCHANGED = 8421504 # Gray (#808080)
DISCORD_COLOR_REMOVED = 16711680 # Red (#FF0000)
DISCORD_COLOR_ADDED = 65280 # Green (#00FF00)
DISCORD_COLOR_CHANGED = 16753920 # Orange (#FFA500)
DISCORD_COLOR_CHANGED_INTO = 3447003 # Blue (#5865F2 - Discord blue)
DISCORD_COLOR_WARNING = 16776960 # Yellow (#FFFF00)
class NotifyDiscordCustom(NotifyDiscord):
"""
Custom Discord notification handler that supports multiple colored embeds
for showing removed (red) and added (green) content separately.
"""
def send(self, body, title="", notify_type=None, attach=None, **kwargs):
"""
Override send method to create custom embeds with red/green colors
for removed/added content when placeholders are present.
"""
# Check if body contains our diff placeholders
has_removed = REMOVED_PLACEMARKER_OPEN in body
has_added = ADDED_PLACEMARKER_OPEN in body
has_changed = CHANGED_PLACEMARKER_OPEN in body
has_changed_into = CHANGED_INTO_PLACEMARKER_OPEN in body
# If we have diff placeholders and we're in markdown/html format, create custom embeds
if (has_removed or has_added or has_changed or has_changed_into) and self.notify_format in (NotifyFormat.MARKDOWN, NotifyFormat.HTML):
return self._send_with_colored_embeds(body, title, notify_type, attach, **kwargs)
# Otherwise, use the parent class's default behavior
return super().send(body, title, notify_type, attach, **kwargs)
def _send_with_colored_embeds(self, body, title, notify_type, attach, **kwargs):
"""
Send Discord message with embeds in the original diff order.
Preserves the sequence: unchanged -> removed -> added -> unchanged, etc.
"""
from datetime import datetime, timezone
payload = {
"tts": self.tts,
"wait": self.tts is False,
}
if self.flags:
payload["flags"] = self.flags
# Acquire image_url
image_url = self.image_url(notify_type)
if self.avatar and (image_url or self.avatar_url):
payload["avatar_url"] = self.avatar_url if self.avatar_url else image_url
if self.user:
payload["username"] = self.user
# Associate our thread_id with our message
params = {"thread_id": self.thread_id} if self.thread_id else None
# Build embeds array preserving order
embeds = []
# Add title as plain bold text in message content (not an embed)
if title:
payload["content"] = f"**{title}**"
# Parse the body into ordered chunks
chunks = self._parse_body_into_chunks(body)
# Discord limits:
# - Max 10 embeds per message
# - Max 6000 characters total across all embeds
# - Max 4096 characters per embed description
max_embeds = 10
max_total_chars = 6000
max_embed_description = 4096
# All 10 embed slots are available for content
max_content_embeds = max_embeds
# Start character count
total_chars = 0
# Create embeds from chunks in order (no titles, just color coding)
for chunk_type, content in chunks:
if not content.strip():
continue
# Truncate individual embed description if needed
if len(content) > max_embed_description:
content = content[:max_embed_description - 3] + "..."
# Check if we're approaching the embed count limit
# We need room for the warning embed, so stop at max_content_embeds - 1
current_content_embeds = len(embeds)
if current_content_embeds >= max_content_embeds - 1:
# Add a truncation notice (this will be the 10th embed)
embeds.append({
"description": "⚠️ Content truncated (Discord 10 embed limit reached) - Tip: Select 'Plain Text' or 'HTML' format for longer diffs",
"color": DISCORD_COLOR_WARNING,
})
break
# Check if adding this embed would exceed total character limit
if total_chars + len(content) > max_total_chars:
# Add a truncation notice
remaining_chars = max_total_chars - total_chars
if remaining_chars > 100:
# Add partial content if we have room
truncated_content = content[:remaining_chars - 100] + "..."
embeds.append({
"description": truncated_content,
"color": (DISCORD_COLOR_UNCHANGED if chunk_type == "unchanged"
else DISCORD_COLOR_REMOVED if chunk_type == "removed"
else DISCORD_COLOR_ADDED),
})
embeds.append({
"description": "⚠️ Content truncated (Discord 6000 char limit reached)\nTip: Select 'Plain Text' or 'HTML' format for longer diffs",
"color": DISCORD_COLOR_WARNING,
})
break
if chunk_type == "unchanged":
embeds.append({
"description": content,
"color": DISCORD_COLOR_UNCHANGED,
})
elif chunk_type == "removed":
embeds.append({
"description": content,
"color": DISCORD_COLOR_REMOVED,
})
elif chunk_type == "added":
embeds.append({
"description": content,
"color": DISCORD_COLOR_ADDED,
})
elif chunk_type == "changed":
# Changed (old value) - use orange to distinguish from pure removal
embeds.append({
"description": content,
"color": DISCORD_COLOR_CHANGED,
})
elif chunk_type == "changed_into":
# Changed into (new value) - use blue to distinguish from pure addition
embeds.append({
"description": content,
"color": DISCORD_COLOR_CHANGED_INTO,
})
total_chars += len(content)
if embeds:
payload["embeds"] = embeds
# Send the payload using parent's _send method
if not self._send(payload, params=params):
return False
# Handle attachments if present
if attach and self.attachment_support:
payload.update({
"tts": False,
"wait": True,
})
payload.pop("embeds", None)
payload.pop("content", None)
payload.pop("allow_mentions", None)
for attachment in attach:
self.logger.info(f"Posting Discord Attachment {attachment.name}")
if not self._send(payload, params=params, attach=attachment):
return False
return True
def _parse_body_into_chunks(self, body):
"""
Parse the body into ordered chunks of (type, content) tuples.
Types: "unchanged", "removed", "added", "changed", "changed_into"
Preserves the original order of the diff.
"""
chunks = []
position = 0
while position < len(body):
# Find the next marker
next_removed = body.find(REMOVED_PLACEMARKER_OPEN, position)
next_added = body.find(ADDED_PLACEMARKER_OPEN, position)
next_changed = body.find(CHANGED_PLACEMARKER_OPEN, position)
next_changed_into = body.find(CHANGED_INTO_PLACEMARKER_OPEN, position)
# Determine which marker comes first
if next_removed == -1 and next_added == -1 and next_changed == -1 and next_changed_into == -1:
# No more markers, rest is unchanged
if position < len(body):
chunks.append(("unchanged", body[position:]))
break
# Find the earliest marker
next_marker_pos = None
next_marker_type = None
# Compare all marker positions to find the earliest
markers = []
if next_removed != -1:
markers.append((next_removed, "removed"))
if next_added != -1:
markers.append((next_added, "added"))
if next_changed != -1:
markers.append((next_changed, "changed"))
if next_changed_into != -1:
markers.append((next_changed_into, "changed_into"))
if markers:
next_marker_pos, next_marker_type = min(markers, key=lambda x: x[0])
# Add unchanged content before the marker
if next_marker_pos > position:
chunks.append(("unchanged", body[position:next_marker_pos]))
# Find the closing marker
if next_marker_type == "removed":
open_marker = REMOVED_PLACEMARKER_OPEN
close_marker = REMOVED_PLACEMARKER_CLOSED
elif next_marker_type == "added":
open_marker = ADDED_PLACEMARKER_OPEN
close_marker = ADDED_PLACEMARKER_CLOSED
elif next_marker_type == "changed":
open_marker = CHANGED_PLACEMARKER_OPEN
close_marker = CHANGED_PLACEMARKER_CLOSED
else: # changed_into
open_marker = CHANGED_INTO_PLACEMARKER_OPEN
close_marker = CHANGED_INTO_PLACEMARKER_CLOSED
close_pos = body.find(close_marker, next_marker_pos)
if close_pos == -1:
# No closing marker, take rest as this type
content = body[next_marker_pos + len(open_marker):]
chunks.append((next_marker_type, content))
break
else:
# Extract content between markers
content = body[next_marker_pos + len(open_marker):close_pos]
chunks.append((next_marker_type, content))
position = close_pos + len(close_marker)
return chunks
# Register the custom Discord handler with Apprise
# This will override the built-in discord:// handler
@notify(on="discord")
def discord_custom_wrapper(body, title, notify_type, meta, body_format=None, *args, **kwargs):
"""
Wrapper function to make the custom Discord handler work with Apprise's decorator system.
Note: This decorator approach may not work for overriding built-in plugins.
The class-based approach above is the proper way to extend NotifyDiscord.
"""
logger.info("Custom Discord handler called")
# This is here for potential future use with decorator-based registration
return True

View File

@@ -1,33 +1,243 @@
import time import time
import apprise import apprise
from apprise import NotifyFormat
from loguru import logger from loguru import logger
from urllib.parse import urlparse
from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL
from .apprise_plugin.custom_handlers import SUPPORTED_HTTP_METHODS
from ..diff import HTML_REMOVED_STYLE, REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED, ADDED_PLACEMARKER_OPEN, HTML_ADDED_STYLE, \
ADDED_PLACEMARKER_CLOSED, CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED, CHANGED_PLACEMARKER_OPEN, \
CHANGED_PLACEMARKER_CLOSED
from ..notification_service import NotificationContextData
def process_notification(n_object, datastore):
def markup_text_links_to_html(body):
"""
Convert plaintext to HTML with clickable links.
Uses Jinja2's escape and Markup for XSS safety.
"""
from linkify_it import LinkifyIt
from markupsafe import Markup, escape
linkify = LinkifyIt()
# Match URLs in the ORIGINAL text (before escaping)
matches = linkify.match(body)
if not matches:
# No URLs, just escape everything
return Markup(escape(body))
result = []
last_index = 0
# Process each URL match
for match in matches:
# Add escaped text before the URL
if match.index > last_index:
text_part = body[last_index:match.index]
result.append(escape(text_part))
# Add the link with escaped URL (both in href and display)
url = match.url
result.append(Markup(f'<a href="{escape(url)}">{escape(url)}</a>'))
last_index = match.last_index
# Add remaining escaped text
if last_index < len(body):
result.append(escape(body[last_index:]))
# Join all parts
return str(Markup(''.join(str(part) for part in result)))
def notification_format_align_with_apprise(n_format : str):
"""
Correctly align changedetection's formats with apprise's formats
Probably these are the same - but good to be sure.
These set the expected OUTPUT format type
:param n_format:
:return:
"""
if n_format.lower().startswith('html'):
# Apprise only knows 'html' not 'htmlcolor' etc, which shouldnt matter here
n_format = NotifyFormat.HTML.value
elif n_format.lower().startswith('markdown'):
# probably the same but just to be safe
n_format = NotifyFormat.MARKDOWN.value
elif n_format.lower().startswith('text'):
# probably the same but just to be safe
n_format = NotifyFormat.TEXT.value
else:
n_format = NotifyFormat.TEXT.value
return n_format
def apply_service_tweaks(url, n_body, n_title, requested_output_format):
# Re 323 - Limit discord length to their 2000 char limit total or it wont send.
# Because different notifications may require different pre-processing, run each sequentially :(
# 2000 bytes minus -
# 200 bytes for the overhead of the _entire_ json payload, 200 bytes for {tts, wait, content} etc headers
# Length of URL - Incase they specify a longer custom avatar_url
if not n_body or not n_body.strip():
return url, n_body, n_title
# So if no avatar_url is specified, add one so it can be correctly calculated into the total payload
parsed = urlparse(url)
k = '?' if not parsed.query else '&'
if url and not 'avatar_url' in url \
and not url.startswith('mail') \
and not url.startswith('post') \
and not url.startswith('get') \
and not url.startswith('delete') \
and not url.startswith('put'):
url += k + f"avatar_url={APPRISE_AVATAR_URL}"
if url.startswith('tgram://'):
# Telegram only supports a limit subset of HTML, remove the '<br>' we place in.
# re https://github.com/dgtlmoon/changedetection.io/issues/555
# @todo re-use an existing library we have already imported to strip all non-allowed tags
n_body = n_body.replace('<br>', '\n')
n_body = n_body.replace('</br>', '\n')
# Use strikethrough for removed content, bold for added content
n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN, '<s>')
n_body = n_body.replace(REMOVED_PLACEMARKER_CLOSED, '</s>')
n_body = n_body.replace(ADDED_PLACEMARKER_OPEN, '<b>')
n_body = n_body.replace(ADDED_PLACEMARKER_CLOSED, '</b>')
# Handle changed/replaced lines (old → new)
n_body = n_body.replace(CHANGED_PLACEMARKER_OPEN, '<s>')
n_body = n_body.replace(CHANGED_PLACEMARKER_CLOSED, '</s>')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_OPEN, '<b>')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_CLOSED, '</b>')
# real limit is 4096, but minus some for extra metadata
payload_max_size = 3600
body_limit = max(0, payload_max_size - len(n_title))
n_title = n_title[0:payload_max_size]
n_body = n_body[0:body_limit]
elif (url.startswith('discord://') or url.startswith('https://discordapp.com/api/webhooks')
or url.startswith('https://discord.com/api'))\
and 'html' in requested_output_format:
# Discord doesn't support HTML, replace <br> with newlines
n_body = n_body.strip().replace('<br>', '\n')
n_body = n_body.replace('</br>', '\n')
# Don't replace placeholders or truncate here - let the custom Discord plugin handle it
# The plugin will use embeds (6000 char limit across all embeds) if placeholders are present,
# or plain content (2000 char limit) otherwise
# Only do placeholder replacement if NOT using htmlcolor (which triggers embeds in custom plugin)
if requested_output_format == 'html':
# No diff placeholders, use Discord markdown for any other formatting
# Use Discord markdown: strikethrough for removed, bold for added
n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN, '~~')
n_body = n_body.replace(REMOVED_PLACEMARKER_CLOSED, '~~')
n_body = n_body.replace(ADDED_PLACEMARKER_OPEN, '**')
n_body = n_body.replace(ADDED_PLACEMARKER_CLOSED, '**')
# Handle changed/replaced lines (old → new)
n_body = n_body.replace(CHANGED_PLACEMARKER_OPEN, '~~')
n_body = n_body.replace(CHANGED_PLACEMARKER_CLOSED, '~~')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_OPEN, '**')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_CLOSED, '**')
# Apply 2000 char limit for plain content
payload_max_size = 1700
body_limit = max(0, payload_max_size - len(n_title))
n_title = n_title[0:payload_max_size]
n_body = n_body[0:body_limit]
# else: our custom Discord plugin will convert any placeholders left over into embeds with color bars
# Is not discord/tgram and they want htmlcolor
elif requested_output_format == 'htmlcolor':
n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN, f'<span style="{HTML_REMOVED_STYLE}">')
n_body = n_body.replace(REMOVED_PLACEMARKER_CLOSED, f'</span>')
n_body = n_body.replace(ADDED_PLACEMARKER_OPEN, f'<span style="{HTML_ADDED_STYLE}">')
n_body = n_body.replace(ADDED_PLACEMARKER_CLOSED, f'</span>')
# Handle changed/replaced lines (old → new)
n_body = n_body.replace(CHANGED_PLACEMARKER_OPEN, f'<span style="{HTML_REMOVED_STYLE}">')
n_body = n_body.replace(CHANGED_PLACEMARKER_CLOSED, f'</span>')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_OPEN, f'<span style="{HTML_ADDED_STYLE}">')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'</span>')
n_body = n_body.replace("\n", '<br>')
elif requested_output_format == 'html':
n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN, '(removed) ')
n_body = n_body.replace(REMOVED_PLACEMARKER_CLOSED, '')
n_body = n_body.replace(ADDED_PLACEMARKER_OPEN, '(added) ')
n_body = n_body.replace(ADDED_PLACEMARKER_CLOSED, '')
n_body = n_body.replace(CHANGED_PLACEMARKER_OPEN, f'(changed) ')
n_body = n_body.replace(CHANGED_PLACEMARKER_CLOSED, f'')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_OPEN, f'(into) ')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'')
n_body = n_body.replace("\n", '<br>')
else: #plaintext etc default
n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN, '(removed) ')
n_body = n_body.replace(REMOVED_PLACEMARKER_CLOSED, '')
n_body = n_body.replace(ADDED_PLACEMARKER_OPEN, '(added) ')
n_body = n_body.replace(ADDED_PLACEMARKER_CLOSED, '')
n_body = n_body.replace(CHANGED_PLACEMARKER_OPEN, f'(changed) ')
n_body = n_body.replace(CHANGED_PLACEMARKER_CLOSED, f'')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_OPEN, f'(into) ')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'')
return url, n_body, n_title
def process_notification(n_object: NotificationContextData, datastore):
from changedetectionio.jinja2_custom import render as jinja_render from changedetectionio.jinja2_custom import render as jinja_render
from . import default_notification_format_for_watch, default_notification_format, valid_notification_formats from . import default_notification_format_for_watch, default_notification_format, valid_notification_formats
# be sure its registered # be sure its registered
from .apprise_plugin.custom_handlers import apprise_http_custom_handler from .apprise_plugin.custom_handlers import apprise_http_custom_handler
# Register custom Discord plugin
from .apprise_plugin.discord import NotifyDiscordCustom
# Create list of custom handler protocols (both http and https versions)
custom_handler_protocols = [f"{method}://" for method in SUPPORTED_HTTP_METHODS]
custom_handler_protocols += [f"{method}s://" for method in SUPPORTED_HTTP_METHODS]
has_custom_handler = any(
url.startswith(tuple(custom_handler_protocols))
for url in n_object['notification_urls']
)
if not isinstance(n_object, NotificationContextData):
raise TypeError(f"Expected NotificationContextData, got {type(n_object)}")
now = time.time() now = time.time()
if n_object.get('notification_timestamp'): if n_object.get('notification_timestamp'):
logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s") logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s")
# Insert variables into the notification content # Insert variables into the notification content
notification_parameters = create_notification_parameters(n_object, datastore) notification_parameters = create_notification_parameters(n_object, datastore)
n_format = valid_notification_formats.get( requested_output_format = valid_notification_formats.get(
n_object.get('notification_format', default_notification_format), n_object.get('notification_format', default_notification_format),
valid_notification_formats[default_notification_format], valid_notification_formats[default_notification_format],
) )
# If we arrived with 'System default' then look it up # If we arrived with 'System default' then look it up
if n_format == default_notification_format_for_watch and datastore.data['settings']['application'].get('notification_format') != default_notification_format_for_watch: if requested_output_format == default_notification_format_for_watch and datastore.data['settings']['application'].get('notification_format') != default_notification_format_for_watch:
# Initially text or whatever # Initially text or whatever
n_format = datastore.data['settings']['application'].get('notification_format', valid_notification_formats[default_notification_format]) requested_output_format = datastore.data['settings']['application'].get('notification_format', valid_notification_formats[default_notification_format]).lower()
requested_output_format_original = requested_output_format
requested_output_format = notification_format_align_with_apprise(n_format=requested_output_format)
logger.trace(f"Complete notification body including Jinja and placeholders calculated in {time.time() - now:.2f}s") logger.trace(f"Complete notification body including Jinja and placeholders calculated in {time.time() - now:.2f}s")
# If we have custom handlers, use invalid format to prevent conversion
# Otherwise use the proper format
if has_custom_handler:
input_format = 'raw-no-convert'
# https://github.com/caronc/apprise/wiki/Development_LogCapture # https://github.com/caronc/apprise/wiki/Development_LogCapture
# Anything higher than or equal to WARNING (which covers things like Connection errors) # Anything higher than or equal to WARNING (which covers things like Connection errors)
# raise it as an exception # raise it as an exception
@@ -39,16 +249,51 @@ def process_notification(n_object, datastore):
apobj = apprise.Apprise(debug=True, asset=apprise_asset) apobj = apprise.Apprise(debug=True, asset=apprise_asset)
# Override Apprise's built-in Discord plugin with our custom one
# This allows us to use colored embeds for diff content
# First remove the built-in discord plugin, then add our custom one
apprise.plugins.N_MGR.remove('discord')
apprise.plugins.N_MGR.add(NotifyDiscordCustom, schemas='discord')
if not n_object.get('notification_urls'): if not n_object.get('notification_urls'):
return None return None
with apprise.LogCapture(level=apprise.logging.DEBUG) as logs: with apprise.LogCapture(level=apprise.logging.DEBUG) as logs:
for url in n_object['notification_urls']: for url in n_object['notification_urls']:
parsed_url = urlparse(url)
prefix_add_to_url = '?' if not parsed_url.query else '&'
# Get the notification body from datastore # Get the notification body from datastore
n_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters) n_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters)
if n_object.get('notification_format', '').startswith('HTML'):
n_body = n_body.replace("\n", '<br>') if n_object.get('markup_text_to_html'):
n_body = markup_text_links_to_html(body=n_body)
# This actually means we request "Markdown to HTML"
if requested_output_format == NotifyFormat.MARKDOWN.value:
output_format = NotifyFormat.HTML.value
input_format = NotifyFormat.MARKDOWN.value
if not 'format=' in url.lower():
url = f"{url}{prefix_add_to_url}format={output_format}"
# Deviation from apprise.
# No conversion, its like they want to send raw HTML but we add linebreaks
elif requested_output_format == NotifyFormat.HTML.value:
# same in and out means apprise wont try to convert
input_format = output_format = NotifyFormat.HTML.value
if not 'format=' in url.lower():
url = f"{url}{prefix_add_to_url}format={output_format}"
else:
# Nothing to be done, leave it as plaintext
# `body_format` Tell apprise what format the INPUT is in
# &format= in URL Tell apprise what format the OUTPUT should be in (it can convert between)
input_format = output_format = NotifyFormat.TEXT.value
if not 'format=' in url.lower():
url = f"{url}{prefix_add_to_url}format={output_format}"
if has_custom_handler:
input_format='raw-no-convert'
n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters) n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)
@@ -64,74 +309,28 @@ def process_notification(n_object, datastore):
logger.info(f">> Process Notification: AppRise notifying {url}") logger.info(f">> Process Notification: AppRise notifying {url}")
url = jinja_render(template_str=url, **notification_parameters) url = jinja_render(template_str=url, **notification_parameters)
# Re 323 - Limit discord length to their 2000 char limit total or it wont send. (url, n_body, n_title) = apply_service_tweaks(url=url, n_body=n_body, n_title=n_title, requested_output_format=requested_output_format_original)
# Because different notifications may require different pre-processing, run each sequentially :(
# 2000 bytes minus -
# 200 bytes for the overhead of the _entire_ json payload, 200 bytes for {tts, wait, content} etc headers
# Length of URL - Incase they specify a longer custom avatar_url
# So if no avatar_url is specified, add one so it can be correctly calculated into the total payload
k = '?' if not '?' in url else '&'
if not 'avatar_url' in url \
and not url.startswith('mail') \
and not url.startswith('post') \
and not url.startswith('get') \
and not url.startswith('delete') \
and not url.startswith('put'):
url += k + f"avatar_url={APPRISE_AVATAR_URL}"
if url.startswith('tgram://'):
# Telegram only supports a limit subset of HTML, remove the '<br>' we place in.
# re https://github.com/dgtlmoon/changedetection.io/issues/555
# @todo re-use an existing library we have already imported to strip all non-allowed tags
n_body = n_body.replace('<br>', '\n')
n_body = n_body.replace('</br>', '\n')
# real limit is 4096, but minus some for extra metadata
payload_max_size = 3600
body_limit = max(0, payload_max_size - len(n_title))
n_title = n_title[0:payload_max_size]
n_body = n_body[0:body_limit]
elif url.startswith('discord://') or url.startswith('https://discordapp.com/api/webhooks') or url.startswith(
'https://discord.com/api'):
# real limit is 2000, but minus some for extra metadata
payload_max_size = 1700
body_limit = max(0, payload_max_size - len(n_title))
n_title = n_title[0:payload_max_size]
n_body = n_body[0:body_limit]
elif url.startswith('mailto'):
# Apprise will default to HTML, so we need to override it
# So that whats' generated in n_body is in line with what is going to be sent.
# https://github.com/caronc/apprise/issues/633#issuecomment-1191449321
if not 'format=' in url and (n_format == 'Text' or n_format == 'Markdown'):
prefix = '?' if not '?' in url else '&'
# Apprise format is lowercase text https://github.com/caronc/apprise/issues/633
n_format = n_format.lower()
url = f"{url}{prefix}format={n_format}"
# If n_format == HTML, then apprise email should default to text/html and we should be sending HTML only
apobj.add(url) apobj.add(url)
sent_objs.append({'title': n_title, sent_objs.append({'title': n_title,
'body': n_body, 'body': n_body,
'url': url, 'url': url})
'body_format': n_format})
# Blast off the notifications tht are set in .add()
apobj.notify( apobj.notify(
title=n_title, title=n_title,
body=n_body, body=n_body,
body_format=n_format, # `body_format` Tell apprise what format the INPUT is in
# &format= in URL Tell apprise what format the OUTPUT should be in (it can convert between)
body_format=input_format,
# False is not an option for AppRise, must be type None # False is not an option for AppRise, must be type None
attach=n_object.get('screenshot', None) attach=n_object.get('screenshot', None)
) )
# Returns empty string if nothing found, multi-line string otherwise # Returns empty string if nothing found, multi-line string otherwise
log_value = logs.getvalue() log_value = logs.getvalue()
if log_value and 'WARNING' in log_value or 'ERROR' in log_value: if log_value and ('WARNING' in log_value or 'ERROR' in log_value):
logger.critical(log_value) logger.critical(log_value)
raise Exception(log_value) raise Exception(log_value)
@@ -141,17 +340,15 @@ def process_notification(n_object, datastore):
# Notification title + body content parameters get created here. # Notification title + body content parameters get created here.
# ( Where we prepare the tokens in the notification to be replaced with actual values ) # ( Where we prepare the tokens in the notification to be replaced with actual values )
def create_notification_parameters(n_object, datastore): def create_notification_parameters(n_object: NotificationContextData, datastore):
from copy import deepcopy if not isinstance(n_object, NotificationContextData):
from . import valid_tokens raise TypeError(f"Expected NotificationContextData, got {type(n_object)}")
# in the case we send a test notification from the main settings, there is no UUID. watch = datastore.data['watching'].get(n_object['uuid'])
uuid = n_object['uuid'] if 'uuid' in n_object else '' if watch:
watch_title = datastore.data['watching'][n_object['uuid']].label
if uuid:
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(n_object['uuid'])
if tags: if tags:
for tag_uuid, tag in tags.items(): for tag_uuid, tag in tags.items():
tag_list.append(tag.get('title')) tag_list.append(tag.get('title'))
@@ -166,14 +363,10 @@ def create_notification_parameters(n_object, datastore):
watch_url = n_object['watch_url'] watch_url = n_object['watch_url']
diff_url = "{}/diff/{}".format(base_url, uuid) diff_url = "{}/diff/{}".format(base_url, n_object['uuid'])
preview_url = "{}/preview/{}".format(base_url, uuid) preview_url = "{}/preview/{}".format(base_url, n_object['uuid'])
# Not sure deepcopy is needed here, but why not n_object.update(
tokens = deepcopy(valid_tokens)
# Valid_tokens also used as a field validator
tokens.update(
{ {
'base_url': base_url, 'base_url': base_url,
'diff_url': diff_url, 'diff_url': diff_url,
@@ -181,13 +374,10 @@ def create_notification_parameters(n_object, datastore):
'watch_tag': watch_tag if watch_tag is not None else '', 'watch_tag': watch_tag if watch_tag is not None else '',
'watch_title': watch_title if watch_title is not None else '', 'watch_title': watch_title if watch_title is not None else '',
'watch_url': watch_url, 'watch_url': watch_url,
'watch_uuid': uuid, 'watch_uuid': n_object['uuid'],
}) })
# n_object will contain diff, diff_added etc etc if watch:
tokens.update(n_object) n_object.update(datastore.data['watching'].get(n_object['uuid']).extra_notification_token_values())
if uuid: return n_object
tokens.update(datastore.data['watching'].get(uuid).extra_notification_token_values())
return tokens

View File

@@ -6,9 +6,51 @@ Extracted from update_worker.py to provide standalone notification functionality
for both sync and async workers for both sync and async workers
""" """
import time
from loguru import logger from loguru import logger
import time
from changedetectionio.notification import default_notification_format
# What is passed around as notification context, also used as the complete list of valid {{ tokens }}
class NotificationContextData(dict):
def __init__(self, initial_data=None, **kwargs):
super().__init__({
'current_snapshot': None,
'diff': None,
'diff_added': None,
'diff_full': None,
'diff_patch': None,
'diff_removed': None,
'notification_timestamp': time.time(),
'screenshot': None,
'triggered_text': None,
'uuid': 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX', # Converted to 'watch_uuid' in create_notification_parameters
'watch_url': 'https://WATCH-PLACE-HOLDER/',
'base_url': None,
'diff_url': None,
'preview_url': None,
'watch_tag': None,
'watch_title': None,
'markup_text_to_html': False, # If automatic conversion of plaintext to HTML should happen
})
# Apply any initial data passed in
self.update({'watch_uuid': self.get('uuid')})
if initial_data:
self.update(initial_data)
# Apply any keyword arguments
if kwargs:
self.update(kwargs)
def set_random_for_validation(self):
import random, string
"""Randomly fills all dict keys with random strings (for validation/testing)."""
for key in self.keys():
if key in ['uuid', 'time', 'watch_uuid']:
continue
rand_str = 'RANDOM-PLACEHOLDER-'+''.join(random.choices(string.ascii_letters + string.digits, k=12))
self[key] = rand_str
class NotificationService: class NotificationService:
""" """
@@ -20,13 +62,16 @@ class NotificationService:
self.datastore = datastore self.datastore = datastore
self.notification_q = notification_q self.notification_q = notification_q
def queue_notification_for_watch(self, n_object, watch): def queue_notification_for_watch(self, n_object: NotificationContextData, watch):
""" """
Queue a notification for a watch with full diff rendering and template variables Queue a notification for a watch with full diff rendering and template variables
""" """
from changedetectionio import diff from changedetectionio import diff
from changedetectionio.notification import default_notification_format_for_watch from changedetectionio.notification import default_notification_format_for_watch
if not isinstance(n_object, NotificationContextData):
raise TypeError(f"Expected NotificationContextData, got {type(n_object)}")
dates = [] dates = []
trigger_text = '' trigger_text = ''
@@ -47,7 +92,6 @@ class NotificationService:
if n_object.get('notification_format') == default_notification_format_for_watch: if n_object.get('notification_format') == default_notification_format_for_watch:
n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format') n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format')
html_colour_enable = False
# HTML needs linebreak, but MarkDown and Text can use a linefeed # HTML needs linebreak, but MarkDown and Text can use a linefeed
if n_object.get('notification_format') == 'HTML': if n_object.get('notification_format') == 'HTML':
line_feed_sep = "<br>" line_feed_sep = "<br>"
@@ -57,7 +101,6 @@ class NotificationService:
line_feed_sep = "<br>" line_feed_sep = "<br>"
# Snapshot will be plaintext on the disk, convert to some kind of HTML # Snapshot will be plaintext on the disk, convert to some kind of HTML
snapshot_contents = snapshot_contents.replace('\n', line_feed_sep) snapshot_contents = snapshot_contents.replace('\n', line_feed_sep)
html_colour_enable = True
else: else:
line_feed_sep = "\n" line_feed_sep = "\n"
@@ -78,16 +121,16 @@ class NotificationService:
n_object.update({ n_object.update({
'current_snapshot': snapshot_contents, 'current_snapshot': snapshot_contents,
'diff': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=line_feed_sep, html_colour=html_colour_enable), 'diff': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=line_feed_sep),
'diff_added': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False, line_feed_sep=line_feed_sep), 'diff_added': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False, line_feed_sep=line_feed_sep),
'diff_full': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True, line_feed_sep=line_feed_sep, html_colour=html_colour_enable), 'diff_full': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True, line_feed_sep=line_feed_sep),
'diff_patch': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=line_feed_sep, patch_format=True), 'diff_patch': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=line_feed_sep, patch_format=True),
'diff_removed': diff.render_diff(prev_snapshot, current_snapshot, include_added=False, line_feed_sep=line_feed_sep), 'diff_removed': diff.render_diff(prev_snapshot, current_snapshot, include_added=False, line_feed_sep=line_feed_sep),
'notification_timestamp': now,
'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None, 'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None,
'triggered_text': triggered_text, 'triggered_text': triggered_text,
'uuid': watch.get('uuid') if watch else None, 'uuid': watch.get('uuid') if watch else None,
'watch_url': watch.get('url') if watch else None, 'watch_url': watch.get('url') if watch else None,
'watch_uuid': watch.get('uuid') if watch else None,
}) })
if watch: if watch:
@@ -140,7 +183,7 @@ class NotificationService:
""" """
Send notification when content changes are detected Send notification when content changes are detected
""" """
n_object = {} n_object = NotificationContextData()
watch = self.datastore.data['watching'].get(watch_uuid) watch = self.datastore.data['watching'].get(watch_uuid)
if not watch: if not watch:
return return
@@ -183,11 +226,26 @@ class NotificationService:
if not watch: if not watch:
return return
n_object = {'notification_title': 'Changedetection.io - Alert - CSS/xPath filter was not present in the page', n_format = self.datastore.data['settings']['application'].get('notification_format', default_notification_format)
'notification_body': "Your configured CSS/xPath filters of '{}' for {{{{watch_url}}}} did not appear on the page after {} attempts, did the page change layout?\n\nLink: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}\n\nThanks - Your omniscient changedetection.io installation :)\n".format( filter_list = ", ".join(watch['include_filters'])
", ".join(watch['include_filters']), # @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_to_html' is not needed
threshold), body = f"""Hello,
'notification_format': 'text'}
Your configured CSS/xPath filters of '{filter_list}' for {{{{watch_url}}}} did not appear on the page after {threshold} attempts.
It's possible the page changed layout and the filter needs updating ( Try the 'Visual Selector' tab )
Edit link: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}
Thanks - Your omniscient changedetection.io installation.
"""
n_object = NotificationContextData({
'notification_title': 'Changedetection.io - Alert - CSS/xPath filter was not present in the page',
'notification_body': body,
'notification_format': n_format,
'markup_text_to_html': n_format.lower().startswith('html')
})
if len(watch['notification_urls']): if len(watch['notification_urls']):
n_object['notification_urls'] = watch['notification_urls'] n_object['notification_urls'] = watch['notification_urls']
@@ -215,12 +273,28 @@ class NotificationService:
if not watch: if not watch:
return return
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts') threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts')
n_object = {'notification_title': "Changedetection.io - Alert - Browser step at position {} could not be run".format(step_n+1), n_format = self.datastore.data['settings']['application'].get('notification_format', default_notification_format).lower()
'notification_body': "Your configured browser step at position {} for {{{{watch_url}}}} " step = step_n + 1
"did not appear on the page after {} attempts, did the page change layout? " # @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_to_html' is not needed
"Does it need a delay added?\n\nLink: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}\n\n"
"Thanks - Your omniscient changedetection.io installation :)\n".format(step_n+1, threshold), # {{{{ }}}} because this will be Jinja2 {{ }} tokens
'notification_format': 'text'} body = f"""Hello,
Your configured browser step at position {step} for the web page watch {{{{watch_url}}}} did not appear on the page after {threshold} attempts, did the page change layout?
The element may have moved and needs editing, or does it need a delay added?
Edit link: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}
Thanks - Your omniscient changedetection.io installation.
"""
n_object = NotificationContextData({
'notification_title': f"Changedetection.io - Alert - Browser step at position {step} could not be run",
'notification_body': body,
'notification_format': n_format,
'markup_text_to_html': n_format.lower().startswith('html')
})
if len(watch['notification_urls']): if len(watch['notification_urls']):
n_object['notification_urls'] = watch['notification_urls'] n_object['notification_urls'] = watch['notification_urls']

View File

@@ -7,6 +7,7 @@ import re
import urllib3 import urllib3
from changedetectionio.conditions import execute_ruleset_against_all_plugins from changedetectionio.conditions import execute_ruleset_against_all_plugins
from changedetectionio.diff import ADDED_PLACEMARKER_OPEN
from changedetectionio.processors import difference_detection_processor from changedetectionio.processors import difference_detection_processor
from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE
from changedetectionio import html_tools, content_fetchers from changedetectionio import html_tools, content_fetchers
@@ -324,13 +325,13 @@ class ContentProcessor:
append_pretty_line_formatting=not self.watch.is_source_type_url append_pretty_line_formatting=not self.watch.is_source_type_url
) )
# Raise error if filter returned nothing # Raise error if filter returned nothing
if not filtered_content.strip(): if not filtered_content.strip():
raise FilterNotFoundInResponse( raise FilterNotFoundInResponse(
msg=self.filter_config.include_filters, msg=self.filter_config.include_filters,
screenshot=self.fetcher.screenshot, screenshot=self.fetcher.screenshot,
xpath_data=self.fetcher.xpath_data xpath_data=self.fetcher.xpath_data
) )
return filtered_content return filtered_content

View File

@@ -15,7 +15,7 @@ find tests/test_*py -type f|while read test_name
do do
echo "TEST RUNNING $test_name" echo "TEST RUNNING $test_name"
# REMOVE_REQUESTS_OLD_SCREENSHOTS disabled so that we can write a screenshot and send it in test_notifications.py without a real browser # REMOVE_REQUESTS_OLD_SCREENSHOTS disabled so that we can write a screenshot and send it in test_notifications.py without a real browser
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest $test_name REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 --tb=long $test_name
done done
echo "RUNNING WITH BASE_URL SET" echo "RUNNING WITH BASE_URL SET"
@@ -23,20 +23,20 @@ echo "RUNNING WITH BASE_URL SET"
# Now re-run some tests with BASE_URL enabled # Now re-run some tests with BASE_URL enabled
# Re #65 - Ability to include a link back to the installation, in the notification. # Re #65 - Ability to include a link back to the installation, in the notification.
export BASE_URL="https://really-unique-domain.io" export BASE_URL="https://really-unique-domain.io"
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py
# Re-run with HIDE_REFERER set - could affect login # Re-run with HIDE_REFERER set - could affect login
export HIDE_REFERER=True export HIDE_REFERER=True
pytest tests/test_access_control.py pytest -vv -s --maxfail=1 tests/test_access_control.py
# Re-run a few tests that will trigger brotli based storage # Re-run a few tests that will trigger brotli based storage
export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5 export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5
pytest tests/test_access_control.py pytest -vv -s --maxfail=1 tests/test_access_control.py
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py
pytest tests/test_backend.py pytest -vv -s --maxfail=1 tests/test_backend.py
pytest tests/test_rss.py pytest -vv -s --maxfail=1 tests/test_rss.py
pytest tests/test_unique_lines.py pytest -vv -s --maxfail=1 tests/test_unique_lines.py
# Try high concurrency # Try high concurrency
FETCH_WORKERS=130 pytest tests/test_history_consistency.py -v -l FETCH_WORKERS=130 pytest tests/test_history_consistency.py -v -l

View File

@@ -2,6 +2,13 @@
$(document).ready(function () { $(document).ready(function () {
function reapplyTableStripes() {
$('.watch-table tbody tr').each(function(index) {
$(this).removeClass('pure-table-odd pure-table-even');
$(this).addClass(index % 2 === 0 ? 'pure-table-odd' : 'pure-table-even');
});
}
function bindSocketHandlerButtonsEvents(socket) { function bindSocketHandlerButtonsEvents(socket) {
$('.ajax-op').on('click.socketHandlerNamespace', function (e) { $('.ajax-op').on('click.socketHandlerNamespace', function (e) {
e.preventDefault(); e.preventDefault();
@@ -101,6 +108,7 @@ $(document).ready(function () {
socket.on('watch_deleted', function (data) { socket.on('watch_deleted', function (data) {
$('tr[data-watch-uuid="' + data.uuid + '"] td').fadeOut(500, function () { $('tr[data-watch-uuid="' + data.uuid + '"] td').fadeOut(500, function () {
$(this).closest('tr').remove(); $(this).closest('tr').remove();
reapplyTableStripes();
}); });
}); });

View File

@@ -228,26 +228,36 @@ class ChangeDetectionStore:
d['settings']['application']['active_base_url'] = active_base_url.strip('" ') d['settings']['application']['active_base_url'] = active_base_url.strip('" ')
return d return d
from pathlib import Path
def delete_path(self, path: Path):
import shutil
"""Delete a file or directory tree, including the path itself."""
if not path.exists():
return
if path.is_file() or path.is_symlink():
path.unlink(missing_ok=True) # deletes a file or symlink
else:
shutil.rmtree(path, ignore_errors=True) # deletes dir *and* its contents
# Delete a single watch by UUID # Delete a single watch by UUID
def delete(self, uuid): def delete(self, uuid):
import pathlib import pathlib
import shutil
with self.lock: with self.lock:
if uuid == 'all': if uuid == 'all':
self.__data['watching'] = {} self.__data['watching'] = {}
time.sleep(1) # Mainly used for testing to allow all items to flush before running next test time.sleep(1) # Mainly used for testing to allow all items to flush before running next test
# GitHub #30 also delete history records
for uuid in self.data['watching']: for uuid in self.data['watching']:
path = pathlib.Path(os.path.join(self.datastore_path, uuid)) path = pathlib.Path(os.path.join(self.datastore_path, uuid))
if os.path.exists(path): if os.path.exists(path):
shutil.rmtree(path) self.delete(uuid)
else: else:
path = pathlib.Path(os.path.join(self.datastore_path, uuid)) path = pathlib.Path(os.path.join(self.datastore_path, uuid))
if os.path.exists(path): if os.path.exists(path):
shutil.rmtree(path) self.delete_path(path)
del self.data['watching'][uuid] del self.data['watching'][uuid]
self.needs_write_urgent = True self.needs_write_urgent = True

View File

@@ -1,14 +1,15 @@
import json
import os
import time import time
import re
from flask import url_for from flask import url_for
from email import message_from_string
from email.policy import default as email_policy
from changedetectionio.diff import HTML_REMOVED_STYLE, HTML_ADDED_STYLE
from changedetectionio.tests.util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, \ from changedetectionio.tests.util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, \
wait_for_all_checks, \ wait_for_all_checks, \
set_longer_modified_response, delete_all_watches set_longer_modified_response, delete_all_watches
from changedetectionio.tests.util import extract_UUID_from_client
import logging import logging
import base64
# NOTE - RELIES ON mailserver as hostname running, see github build recipes # NOTE - RELIES ON mailserver as hostname running, see github build recipes
smtp_test_server = 'mailserver' smtp_test_server = 'mailserver'
@@ -50,7 +51,7 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas
url_for("settings.settings_page"), url_for("settings.settings_page"),
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": "fallback-body<br> " + default_notification_body, "application-notification_body": "some text\nfallback-body<br> " + default_notification_body,
"application-notification_format": 'HTML', "application-notification_format": 'HTML',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
@@ -77,19 +78,229 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas
time.sleep(3) time.sleep(3)
msg = get_last_message_from_smtp_server() msg_raw = get_last_message_from_smtp_server()
assert len(msg) >= 1 assert len(msg_raw) >= 1
# The email should have two bodies, and the text/html part should be <br> # Parse the email properly using Python's email library
assert 'Content-Type: text/plain' in msg msg = message_from_string(msg_raw, policy=email_policy)
assert '(added) So let\'s see what happens.\r\n' in msg # The plaintext part with \r\n
assert 'Content-Type: text/html' in msg # The email should have two bodies (multipart/alternative with text/plain and text/html)
assert '(added) So let\'s see what happens.<br>' in msg # the html part assert msg.is_multipart()
assert msg.get_content_type() == 'multipart/alternative'
# Get the parts
parts = list(msg.iter_parts())
assert len(parts) == 2
# First part should be text/plain (the auto-generated plaintext version)
text_part = parts[0]
assert text_part.get_content_type() == 'text/plain'
text_content = text_part.get_content()
assert '(added) So let\'s see what happens.\r\n' in text_content # The plaintext part
assert 'fallback-body\r\n' in text_content # The plaintext part
# Second part should be text/html
html_part = parts[1]
assert html_part.get_content_type() == 'text/html'
html_content = html_part.get_content()
assert 'some text<br>' in html_content # We converted \n from the notification body
assert 'fallback-body<br>' in html_content # kept the original <br>
assert '(added) So let\'s see what happens.<br>' in html_content # the html part
delete_all_watches(client)
def test_check_notification_plaintext_format(client, live_server, measure_memory_usage):
set_original_response()
notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'
#####################
# Set this up for when we remove the notification from the watch, it should fallback with these details
res = client.post(
url_for("settings.settings_page"),
data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": "some text\n" + default_notification_body,
"application-notification_format": 'Plain Text',
"requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"},
follow_redirects=True
)
assert b"Settings updated." in res.data
# Add a watch and trigger a HTTP POST
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
time.sleep(2)
set_longer_modified_response()
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
time.sleep(3)
msg_raw = get_last_message_from_smtp_server()
assert len(msg_raw) >= 1
# Parse the email properly using Python's email library
msg = message_from_string(msg_raw, policy=email_policy)
# The email should be plain text only (not multipart)
assert not msg.is_multipart()
assert msg.get_content_type() == 'text/plain'
# Get the plain text content
text_content = msg.get_content()
assert '(added) So let\'s see what happens.\r\n' in text_content # The plaintext part
# Should NOT contain HTML
assert '<br>' not in text_content # We should not have HTML in plain text
delete_all_watches(client)
def test_check_notification_html_color_format(client, live_server, measure_memory_usage):
set_original_response()
notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'
#####################
# Set this up for when we remove the notification from the watch, it should fallback with these details
res = client.post(
url_for("settings.settings_page"),
data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": "some text\n" + default_notification_body,
"application-notification_format": 'HTML Color',
"requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"},
follow_redirects=True
)
assert b"Settings updated." in res.data
# Add a watch and trigger a HTTP POST
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "tags": 'nice one'},
follow_redirects=True
)
assert b"Watch added" in res.data
wait_for_all_checks(client)
set_longer_modified_response()
time.sleep(2)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
time.sleep(3)
msg_raw = get_last_message_from_smtp_server()
assert len(msg_raw) >= 1
# Parse the email properly using Python's email library
msg = message_from_string(msg_raw, policy=email_policy)
# The email should have two bodies (multipart/alternative with text/plain and text/html)
assert msg.is_multipart()
assert msg.get_content_type() == 'multipart/alternative'
# Get the parts
parts = list(msg.iter_parts())
assert len(parts) == 2
# First part should be text/plain (the auto-generated plaintext version)
text_part = parts[0]
assert text_part.get_content_type() == 'text/plain'
text_content = text_part.get_content()
assert 'So let\'s see what happens.\r\n' in text_content # The plaintext part
assert '(added)' not in text_content # Because apprise only dumb converts the html to text
# Second part should be text/html with color styling
html_part = parts[1]
assert html_part.get_content_type() == 'text/html'
html_content = html_part.get_content()
assert HTML_REMOVED_STYLE in html_content
assert HTML_ADDED_STYLE in html_content
assert 'some text<br>' in html_content
delete_all_watches(client)
def test_check_notification_markdown_format(client, live_server, measure_memory_usage):
set_original_response()
notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'
#####################
# Set this up for when we remove the notification from the watch, it should fallback with these details
res = client.post(
url_for("settings.settings_page"),
data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": "*header*\n\nsome text\n" + default_notification_body,
"application-notification_format": 'Markdown to HTML',
"requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"},
follow_redirects=True
)
assert b"Settings updated." in res.data
# Add a watch and trigger a HTTP POST
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "tags": 'nice one'},
follow_redirects=True
)
assert b"Watch added" in res.data
wait_for_all_checks(client)
set_longer_modified_response()
time.sleep(2)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
time.sleep(3)
msg_raw = get_last_message_from_smtp_server()
assert len(msg_raw) >= 1
# Parse the email properly using Python's email library
msg = message_from_string(msg_raw, policy=email_policy)
# The email should have two bodies (multipart/alternative with text/plain and text/html)
assert msg.is_multipart()
assert msg.get_content_type() == 'multipart/alternative'
# Get the parts
parts = list(msg.iter_parts())
assert len(parts) == 2
# First part should be text/plain (the auto-generated plaintext version)
text_part = parts[0]
assert text_part.get_content_type() == 'text/plain'
text_content = text_part.get_content()
assert '(added) So let\'s see what happens.\r\n' in text_content # The plaintext part
# Second part should be text/html and roughly converted from markdown to HTML
html_part = parts[1]
assert html_part.get_content_type() == 'text/html'
html_content = html_part.get_content()
assert '<p><em>header</em></p>' in html_content
assert '(added) So let\'s see what happens.<br' in html_content
delete_all_watches(client) delete_all_watches(client)
def test_check_notification_email_formats_default_Text_override_HTML(client, live_server, measure_memory_usage): def test_check_notification_email_formats_default_Text_override_HTML(client, live_server, measure_memory_usage):
## live_server_setup(live_server) # Setup on conftest per function
# HTML problems? see this # HTML problems? see this
# https://github.com/caronc/apprise/issues/633 # https://github.com/caronc/apprise/issues/633
@@ -115,7 +326,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": notification_body, "application-notification_body": notification_body,
"application-notification_format": 'Text', "application-notification_format": 'Plain Text',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True
@@ -139,15 +350,21 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
wait_for_all_checks(client) wait_for_all_checks(client)
time.sleep(3) time.sleep(3)
msg = get_last_message_from_smtp_server() msg_raw = get_last_message_from_smtp_server()
assert len(msg) >= 1 assert len(msg_raw) >= 1
# with open('/tmp/m.txt', 'w') as f: # with open('/tmp/m.txt', 'w') as f:
# f.write(msg) # f.write(msg_raw)
# Parse the email properly using Python's email library
msg = message_from_string(msg_raw, policy=email_policy)
# The email should not have two bodies, should be TEXT only # The email should not have two bodies, should be TEXT only
assert not msg.is_multipart()
assert msg.get_content_type() == 'text/plain'
assert 'Content-Type: text/plain' in msg # Get the plain text content
assert '(added) So let\'s see what happens.\r\n' in msg # The plaintext part with \r\n text_content = msg.get_content()
assert '(added) So let\'s see what happens.\r\n' in text_content # The plaintext part
set_original_response() set_original_response()
# Now override as HTML format # Now override as HTML format
@@ -164,18 +381,34 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
wait_for_all_checks(client) wait_for_all_checks(client)
time.sleep(3) time.sleep(3)
msg = get_last_message_from_smtp_server() msg_raw = get_last_message_from_smtp_server()
assert len(msg) >= 1 assert len(msg_raw) >= 1
# The email should have two bodies, and the text/html part should be <br> # Parse the email properly using Python's email library
assert 'Content-Type: text/plain' in msg msg = message_from_string(msg_raw, policy=email_policy)
assert '(removed) So let\'s see what happens.\r\n' in msg # The plaintext part with \n
assert 'Content-Type: text/html' in msg # The email should have two bodies (multipart/alternative)
assert '(removed) So let\'s see what happens.<br>' in msg # the html part assert msg.is_multipart()
assert msg.get_content_type() == 'multipart/alternative'
# Get the parts
parts = list(msg.iter_parts())
assert len(parts) == 2
# First part should be text/plain
text_part = parts[0]
assert text_part.get_content_type() == 'text/plain'
text_content = text_part.get_content()
assert '(removed) So let\'s see what happens.\r\n' in text_content # The plaintext part
# Second part should be text/html
html_part = parts[1]
assert html_part.get_content_type() == 'text/html'
html_content = html_part.get_content()
assert '(removed) So let\'s see what happens.<br>' in html_content # the html part
# https://github.com/dgtlmoon/changedetection.io/issues/2103 # https://github.com/dgtlmoon/changedetection.io/issues/2103
assert '<h1>Test</h1>' in msg assert '<h1>Test</h1>' in html_content
assert '&lt;' not in msg assert '&lt;' not in html_content
assert 'Content-Type: text/html' in msg
delete_all_watches(client) delete_all_watches(client)

View File

@@ -6,6 +6,9 @@ from flask import url_for
from .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output, delete_all_watches from .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output, delete_all_watches
import time import time
from ..diff import ADDED_PLACEMARKER_OPEN
def set_original(excluding=None, add_line=None): def set_original(excluding=None, add_line=None):
test_return_data = """<html> test_return_data = """<html>
<body> <body>
@@ -121,6 +124,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
"application-notification_body": 'triggered text was -{{triggered_text}}- ### 网站监测 内容更新了 ####', "application-notification_body": 'triggered text was -{{triggered_text}}- ### 网站监测 内容更新了 ####',
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
"application-notification_urls": test_notification_url, "application-notification_urls": test_notification_url,
"application-notification_format": 'Plain Text',
"application-minutes_between_check": 180, "application-minutes_between_check": 180,
"application-fetch_backend": "html_requests" "application-fetch_backend": "html_requests"
}, },
@@ -174,6 +178,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
assert os.path.isfile("test-datastore/notification.txt"), "Notification fired because I can see the output file" assert os.path.isfile("test-datastore/notification.txt"), "Notification fired because I can see the output file"
with open("test-datastore/notification.txt", 'rb') as f: with open("test-datastore/notification.txt", 'rb') as f:
response = f.read() response = f.read()
assert ADDED_PLACEMARKER_OPEN.encode('utf-8') not in response # _apply_diff_filtering shouldnt add something here
assert b'-Oh yes please' in response assert b'-Oh yes please' in response
assert '网站监测 内容更新了'.encode('utf-8') in response assert '网站监测 内容更新了'.encode('utf-8') in response

View File

@@ -86,14 +86,16 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se
"Diff Full: {{diff_full}}\n" "Diff Full: {{diff_full}}\n"
"Diff as Patch: {{diff_patch}}\n" "Diff as Patch: {{diff_patch}}\n"
":-)", ":-)",
"notification_format": "Text"} "notification_format": 'Plain Text'}
notification_form_data.update({ notification_form_data.update({
"url": test_url, "url": test_url,
"tags": "my tag", "tags": "my tag",
"title": "my title", "title": "my title",
"headers": "", "headers": "",
"include_filters": '.ticket-available', # preprended with extra filter that intentionally doesn't match any entry,
# notification should still be sent even if first filter does not match (PR#3516)
"include_filters": ".non-matching-selector\n.ticket-available",
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"time_between_check_use_default": "y"}) "time_between_check_use_default": "y"})

View File

@@ -1,10 +1,8 @@
import os import os
import time import time
from loguru import logger
from flask import url_for from flask import url_for
from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks, \ from .util import set_original_response, wait_for_all_checks, wait_for_notification_endpoint_output
wait_for_notification_endpoint_output from ..notification import valid_notification_formats
from changedetectionio.model import App
def set_response_with_filter(): def set_response_with_filter():
@@ -23,13 +21,14 @@ def set_response_with_filter():
f.write(test_return_data) f.write(test_return_data)
return None return None
def run_filter_test(client, live_server, content_filter): def run_filter_test(client, live_server, content_filter, app_notification_format):
# Response WITHOUT the filter ID element # Response WITHOUT the filter ID element
set_original_response() set_original_response()
live_server.app.config['DATASTORE'].data['settings']['application']['notification_format'] = app_notification_format
# Goto the edit page, add our ignore text # Goto the edit page, add our ignore text
notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'post')
# Add our URL to the import page # Add our URL to the import page
test_url = url_for('test_endpoint', _external=True) test_url = url_for('test_endpoint', _external=True)
@@ -64,7 +63,7 @@ def run_filter_test(client, live_server, content_filter):
"Diff Full: {{diff_full}}\n" "Diff Full: {{diff_full}}\n"
"Diff as Patch: {{diff_patch}}\n" "Diff as Patch: {{diff_patch}}\n"
":-)", ":-)",
"notification_format": "Text", "notification_format": 'Plain 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", "time_between_check_use_default": "y",
@@ -127,8 +126,23 @@ def run_filter_test(client, live_server, content_filter):
with open("test-datastore/notification.txt", 'r') as f: with open("test-datastore/notification.txt", 'r') as f:
notification = f.read() notification = f.read()
assert 'CSS/xPath filter was not present in the page' in notification assert 'Your configured CSS/xPath filters' in notification
assert content_filter.replace('"', '\\"') in notification
# Text (or HTML conversion) markup to make the notifications a little nicer should have worked
if app_notification_format.startswith('html'):
# apprise should have used sax-escape (&#39; instead of &quot;, " etc), lets check it worked
from apprise.conversion import convert_between
from apprise.common import NotifyFormat
escaped_filter = convert_between(NotifyFormat.TEXT, NotifyFormat.HTML, content_filter)
assert escaped_filter in notification or escaped_filter.replace('&quot;', '&#34;') in notification
assert 'a href="' in notification # Quotes should still be there so the link works
else:
assert 'a href' not in notification
assert content_filter in notification
# Remove it and prove that it doesn't trigger when not expected # Remove it and prove that it doesn't trigger when not expected
# It should register a change, but no 'filter not found' # It should register a change, but no 'filter not found'
@@ -159,14 +173,20 @@ def run_filter_test(client, live_server, content_filter):
os.unlink("test-datastore/notification.txt") os.unlink("test-datastore/notification.txt")
def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage): def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage):
# # live_server_setup(live_server) # Setup on conftest per function # # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client, live_server,'#nope-doesnt-exist') run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('HTML Color'))
# Check markup send conversion didnt affect plaintext preference
run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('Plain Text'))
def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage): def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage):
# # live_server_setup(live_server) # Setup on conftest per function # # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client, live_server, '//*[@id="nope-doesnt-exist"]') run_filter_test(client=client, live_server=live_server, content_filter='//*[@id="nope-doesnt-exist"]', app_notification_format=valid_notification_formats.get('HTML Color'))
# Test that notification is never sent # Test that notification is never sent
def test_basic_markup_from_text(client, live_server, measure_memory_usage):
# Test the notification error templates convert to HTML if needed (link activate)
from ..notification.handler import markup_text_links_to_html
x = markup_text_links_to_html("hello https://google.com")
assert 'a href' in x

View File

@@ -195,7 +195,7 @@ def test_group_tag_notification(client, live_server, measure_memory_usage):
"Diff as Patch: {{diff_patch}}\n" "Diff as Patch: {{diff_patch}}\n"
":-)", ":-)",
"notification_screenshot": True, "notification_screenshot": True,
"notification_format": "Text", "notification_format": 'Plain Text',
"title": "test-tag"} "title": "test-tag"}
res = client.post( res = client.post(

View File

@@ -2,7 +2,8 @@
# coding=utf-8 # coding=utf-8
import time import time
from flask import url_for, escape from flask import url_for
from markupsafe import escape
from . util import live_server_setup, wait_for_all_checks, delete_all_watches from . util import live_server_setup, wait_for_all_checks, delete_all_watches
import pytest import pytest
jq_support = True jq_support = True

View File

@@ -101,7 +101,7 @@ def test_check_notification(client, live_server, measure_memory_usage):
"Diff as Patch: {{diff_patch}}\n" "Diff as Patch: {{diff_patch}}\n"
":-)", ":-)",
"notification_screenshot": True, "notification_screenshot": True,
"notification_format": "Text"} "notification_format": 'Plain Text'}
notification_form_data.update({ notification_form_data.update({
"url": test_url, "url": test_url,
@@ -267,7 +267,7 @@ def test_notification_validation(client, live_server, measure_memory_usage):
# data={"notification_urls": 'json://localhost/foobar', # data={"notification_urls": 'json://localhost/foobar',
# "notification_title": "", # "notification_title": "",
# "notification_body": "", # "notification_body": "",
# "notification_format": "Text", # "notification_format": 'Plain Text',
# "url": test_url, # "url": test_url,
# "tag": "my tag", # "tag": "my tag",
# "title": "my title", # "title": "my title",
@@ -284,6 +284,27 @@ def test_notification_validation(client, live_server, measure_memory_usage):
) )
def test_notification_urls_jinja2_apprise_integration(client, live_server, measure_memory_usage):
#
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation
test_notification_url = "hassio://127.0.0.1/longaccesstoken?verify=no&nid={{watch_uuid}}"
res = client.post(
url_for("settings.settings_page"),
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
"application-notification_body": '{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了" }',
"application-notification_format": default_notification_format,
"application-notification_urls": test_notification_url,
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }} ",
},
follow_redirects=True
)
assert b'Settings updated' in res.data
def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_memory_usage): def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_memory_usage):
@@ -294,7 +315,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
# CUSTOM JSON BODY CHECK for POST:// # CUSTOM JSON BODY CHECK for POST://
set_original_response() set_original_response()
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?status_code=204&xxx={{ watch_url }}&+custom-header=123&+second=hello+world%20%22space%22" test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?status_code=204&watch_uuid={{ watch_uuid }}&xxx={{ watch_url }}&now={% now 'Europe/London', '%Y-%m-%d' %}&+custom-header=123&+second=hello+world%20%22space%22"
res = client.post( res = client.post(
url_for("settings.settings_page"), url_for("settings.settings_page"),
@@ -320,6 +341,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
) )
assert b"Watch added" in res.data assert b"Watch added" in res.data
watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
wait_for_all_checks(client) wait_for_all_checks(client)
set_modified_response() set_modified_response()
@@ -349,6 +371,11 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
assert 'xxx=http' in notification_url assert 'xxx=http' in notification_url
# apprise style headers should be stripped # apprise style headers should be stripped
assert 'custom-header' not in notification_url assert 'custom-header' not in notification_url
# Check jinja2 custom arrow/jinja2-time replace worked
assert 'now=2' in notification_url
# Check our watch_uuid appeared
assert f'watch_uuid={watch_uuid}' in notification_url
with open("test-datastore/notification-headers.txt", 'r') as f: with open("test-datastore/notification-headers.txt", 'r') as f:
notification_headers = f.read() notification_headers = f.read()
@@ -356,7 +383,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
assert 'second: hello world "space"' in notification_headers.lower() assert 'second: hello world "space"' in notification_headers.lower()
# Should always be automatically detected as JSON content type even when we set it as 'Text' (default) # Should always be automatically detected as JSON content type even when we set it as 'Plain Text' (default)
assert os.path.isfile("test-datastore/notification-content-type.txt") assert os.path.isfile("test-datastore/notification-content-type.txt")
with open("test-datastore/notification-content-type.txt", 'r') as f: with open("test-datastore/notification-content-type.txt", 'r') as f:
assert 'application/json' in f.read() assert 'application/json' in f.read()
@@ -416,7 +443,6 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
assert res.status_code != 400 assert res.status_code != 400
assert res.status_code != 500 assert res.status_code != 500
with open("test-datastore/notification.txt", 'r') as f: with open("test-datastore/notification.txt", 'r') as f:
x = f.read() x = f.read()
assert test_body in x assert test_body in x
@@ -459,7 +485,7 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
def _test_color_notifications(client, notification_body_token): def _test_color_notifications(client, notification_body_token):
from changedetectionio.diff import ADDED_STYLE, REMOVED_STYLE from changedetectionio.diff import HTML_ADDED_STYLE, HTML_REMOVED_STYLE
set_original_response() set_original_response()
@@ -507,7 +533,7 @@ def _test_color_notifications(client, notification_body_token):
with open("test-datastore/notification.txt", 'r') as f: with open("test-datastore/notification.txt", 'r') as f:
x = f.read() x = f.read()
assert f'<span style="{REMOVED_STYLE}">Which is across multiple lines' in x assert f'<span style="{HTML_REMOVED_STYLE}">Which is across multiple lines' in x
client.get( client.get(
@@ -515,9 +541,7 @@ def _test_color_notifications(client, notification_body_token):
follow_redirects=True follow_redirects=True
) )
# Just checks the format of the colour notifications was correct
def test_html_color_notifications(client, live_server, measure_memory_usage): def test_html_color_notifications(client, live_server, measure_memory_usage):
_test_color_notifications(client, '{{diff}}') _test_color_notifications(client, '{{diff}}')
_test_color_notifications(client, '{{diff_full}}') _test_color_notifications(client, '{{diff_full}}')

View File

@@ -30,7 +30,7 @@ def test_check_notification_error_handling(client, live_server, measure_memory_u
data={"notification_urls": f"{broken_notification_url}\r\n{working_notification_url}", data={"notification_urls": f"{broken_notification_url}\r\n{working_notification_url}",
"notification_title": "xxx", "notification_title": "xxx",
"notification_body": "xxxxx", "notification_body": "xxxxx",
"notification_format": "Text", "notification_format": 'Plain Text',
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"title": "", "title": "",

View File

@@ -24,18 +24,18 @@ class TestDiffBuilder(unittest.TestCase):
output = output.split("\n") output = output.split("\n")
# Check that placeholders are present (they get replaced in apply_service_tweaks)
self.assertIn('(changed) ok', output) self.assertTrue(any(diff.CHANGED_PLACEMARKER_OPEN in line and 'ok' in line for line in output))
self.assertIn('(into) xok', output) self.assertTrue(any(diff.CHANGED_INTO_PLACEMARKER_OPEN in line and 'xok' in line for line in output))
self.assertIn('(into) next-x-ok', output) self.assertTrue(any(diff.CHANGED_INTO_PLACEMARKER_OPEN in line and 'next-x-ok' in line for line in output))
self.assertIn('(added) and something new', output) self.assertTrue(any(diff.ADDED_PLACEMARKER_OPEN in line and 'and something new' in line for line in output))
with open(base_dir + "/test-content/after-2.txt", 'r') as f: with open(base_dir + "/test-content/after-2.txt", 'r') as f:
newest_version_file_contents = f.read() newest_version_file_contents = f.read()
output = diff.render_diff(previous_version_file_contents, newest_version_file_contents) output = diff.render_diff(previous_version_file_contents, newest_version_file_contents)
output = output.split("\n") output = output.split("\n")
self.assertIn('(removed) for having learned computerese,', output) self.assertTrue(any(diff.REMOVED_PLACEMARKER_OPEN in line and 'for having learned computerese,' in line for line in output))
self.assertIn('(removed) I continue to examine bits, bytes and words', output) self.assertTrue(any(diff.REMOVED_PLACEMARKER_OPEN in line and 'I continue to examine bits, bytes and words' in line for line in output))
#diff_removed #diff_removed
with open(base_dir + "/test-content/before.txt", 'r') as f: with open(base_dir + "/test-content/before.txt", 'r') as f:
@@ -45,18 +45,18 @@ class TestDiffBuilder(unittest.TestCase):
newest_version_file_contents = f.read() newest_version_file_contents = f.read()
output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False) output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False)
output = output.split("\n") output = output.split("\n")
self.assertIn('(changed) ok', output) self.assertTrue(any(diff.CHANGED_PLACEMARKER_OPEN in line and 'ok' in line for line in output))
self.assertIn('(into) xok', output) self.assertTrue(any(diff.CHANGED_INTO_PLACEMARKER_OPEN in line and 'xok' in line for line in output))
self.assertIn('(into) next-x-ok', output) self.assertTrue(any(diff.CHANGED_INTO_PLACEMARKER_OPEN in line and 'next-x-ok' in line for line in output))
self.assertNotIn('(added) and something new', output) self.assertFalse(any(diff.ADDED_PLACEMARKER_OPEN in line and 'and something new' in line for line in output))
#diff_removed #diff_removed
with open(base_dir + "/test-content/after-2.txt", 'r') as f: with open(base_dir + "/test-content/after-2.txt", 'r') as f:
newest_version_file_contents = f.read() newest_version_file_contents = f.read()
output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False) output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False)
output = output.split("\n") output = output.split("\n")
self.assertIn('(removed) for having learned computerese,', output) self.assertTrue(any(diff.REMOVED_PLACEMARKER_OPEN in line and 'for having learned computerese,' in line for line in output))
self.assertIn('(removed) I continue to examine bits, bytes and words', output) self.assertTrue(any(diff.REMOVED_PLACEMARKER_OPEN in line and 'I continue to examine bits, bytes and words' in line for line in output))
def test_expected_diff_patch_output(self): def test_expected_diff_patch_output(self):
base_dir = os.path.dirname(__file__) base_dir = os.path.dirname(__file__)

View File

@@ -10,14 +10,14 @@ flask_restful
flask_cors # For the Chrome extension to operate flask_cors # For the Chrome extension to operate
janus # Thread-safe async/sync queue bridge janus # Thread-safe async/sync queue bridge
flask_wtf~=1.2 flask_wtf~=1.2
flask~=2.3 flask~=3.1
flask-socketio~=5.5.1 flask-socketio~=5.5.1
python-socketio~=5.13.0 python-socketio~=5.13.0
python-engineio~=4.12.3 python-engineio~=4.12.3
inscriptis~=2.2 inscriptis~=2.2
pytz pytz
timeago~=1.0 timeago~=1.0
validators~=0.21 validators~=0.35
# Set these versions together to avoid a RequestsDependencyWarning # Set these versions together to avoid a RequestsDependencyWarning
@@ -42,6 +42,9 @@ jsonpath-ng~=1.5.3
# Notification library # Notification library
apprise==1.9.5 apprise==1.9.5
# Lightweight URL linkifier for notifications
linkify-it-py
# - Needed for apprise/spush, and maybe others? hopefully doesnt trigger a rust compile. # - 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 # - 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) # Pinned to 43.0.1 for ARM compatibility (45.x may not have pre-built ARM wheels)
@@ -53,7 +56,7 @@ cryptography==44.0.1
paho-mqtt!=2.0.* paho-mqtt!=2.0.*
# Used for CSS filtering, JSON extraction from HTML # Used for CSS filtering, JSON extraction from HTML
beautifulsoup4>=4.0.0,<=4.13.5 beautifulsoup4>=4.0.0,<=4.14.2
# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe. # XPath filtering, lxml is required by bs4 anyway, but put it here to be safe.
# #2328 - 5.2.0 and 5.2.1 had extra CPU flag CFLAGS set which was not compatible on older hardware # #2328 - 5.2.0 and 5.2.1 had extra CPU flag CFLAGS set which was not compatible on older hardware
@@ -63,14 +66,10 @@ lxml >=4.8.0,<6,!=5.2.0,!=5.2.1
# XPath 2.0-3.1 support - 4.2.0 had issues, 4.1.5 stable # XPath 2.0-3.1 support - 4.2.0 had issues, 4.1.5 stable
# Consider updating to latest stable version periodically # Consider updating to latest stable version periodically
elementpath==4.1.5 elementpath==5.0.4
selenium~=4.31.0 selenium~=4.31.0
# https://github.com/pallets/werkzeug/issues/2985
# Maybe related to pytest?
werkzeug==3.0.6
# Templating, so far just in the URLs but in the future can be for the notifications also # Templating, so far just in the URLs but in the future can be for the notifications also
jinja2~=3.1 jinja2~=3.1
arrow arrow

View File

@@ -5,6 +5,8 @@ import re
import sys import sys
from setuptools import setup, find_packages from setuptools import setup, find_packages
from setuptools.command.build_py import build_py
import shutil
here = os.path.abspath(os.path.dirname(__file__)) here = os.path.abspath(os.path.dirname(__file__))
@@ -22,6 +24,20 @@ def find_version(*file_paths):
raise RuntimeError("Unable to find version string.") raise RuntimeError("Unable to find version string.")
class BuildPyCommand(build_py):
"""Custom build command to copy api-spec.yaml to the package."""
def run(self):
build_py.run(self)
# Ensure the docs directory exists in the build output
docs_dir = os.path.join(self.build_lib, 'changedetectionio', 'docs')
os.makedirs(docs_dir, exist_ok=True)
# Copy api-spec.yaml to the package
shutil.copy(
os.path.join(here, 'docs', 'api-spec.yaml'),
os.path.join(docs_dir, 'api-spec.yaml')
)
install_requires = open('requirements.txt').readlines() install_requires = open('requirements.txt').readlines()
setup( setup(
@@ -37,9 +53,10 @@ setup(
scripts=["changedetection.py"], scripts=["changedetection.py"],
author='dgtlmoon', author='dgtlmoon',
url='https://changedetection.io', url='https://changedetection.io',
packages=['changedetectionio'], packages=find_packages(include=['changedetectionio', 'changedetectionio.*']),
include_package_data=True, include_package_data=True,
install_requires=install_requires, install_requires=install_requires,
cmdclass={'build_py': BuildPyCommand},
license="Apache License 2.0", license="Apache License 2.0",
python_requires=">= 3.10", python_requires=">= 3.10",
classifiers=['Intended Audience :: Customer Service', classifiers=['Intended Audience :: Customer Service',