Compare commits

...

12 Commits

Author SHA1 Message Date
dgtlmoon
951903287a revert accidential change
Some checks failed
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-23 13:12:00 +02:00
dgtlmoon
42c1f651e8 Rename type 2025-10-23 12:57:00 +02:00
dgtlmoon
4f5e7af950 Re #3526 - Refactor/cleanup notification handling and rename 'Markdown' to "Markdown to HTML" to make more sense. 2025-10-23 12:42:32 +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
15 changed files with 398 additions and 128 deletions

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

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

@@ -89,9 +89,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,10 +7,10 @@ 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
} }

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

@@ -3,7 +3,9 @@ import time
import apprise import apprise
from apprise import NotifyFormat 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 ..notification_service import NotificationContextData from ..notification_service import NotificationContextData
@@ -51,24 +53,66 @@ def notification_format_align_with_apprise(n_format : str):
""" """
Correctly align changedetection's formats with apprise's formats Correctly align changedetection's formats with apprise's formats
Probably these are the same - but good to be sure. Probably these are the same - but good to be sure.
These set the expected OUTPUT format type
:param n_format: :param n_format:
:return: :return:
""" """
if n_format.lower().startswith('html'): if n_format.lower().startswith('html'):
# Apprise only knows 'html' not 'htmlcolor' etc, which shouldnt matter here # Apprise only knows 'html' not 'htmlcolor' etc, which shouldnt matter here
n_format = NotifyFormat.HTML n_format = NotifyFormat.HTML.value
elif n_format.lower().startswith('markdown'): elif n_format.lower().startswith('markdown'):
# probably the same but just to be safe # probably the same but just to be safe
n_format = NotifyFormat.MARKDOWN n_format = NotifyFormat.MARKDOWN.value
elif n_format.lower().startswith('text'): elif n_format.lower().startswith('text'):
# probably the same but just to be safe # probably the same but just to be safe
n_format = NotifyFormat.TEXT n_format = NotifyFormat.TEXT.value
else: else:
n_format = NotifyFormat.TEXT n_format = NotifyFormat.TEXT.value
return n_format
def apply_service_tweaks(url, n_body, n_title):
# Re 323 - Limit discord length to their 2000 char limit total or it wont send.
# Because different notifications may require different pre-processing, run each sequentially :(
# 2000 bytes minus -
# 200 bytes for the overhead of the _entire_ json payload, 200 bytes for {tts, wait, content} etc headers
# Length of URL - Incase they specify a longer custom avatar_url
# So if no avatar_url is specified, add one so it can be correctly calculated into the total payload
parsed = urlparse(url)
k = '?' if not parsed.query 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]
return url, n_body, n_title
# Must be str for apprise notify body_format
return str(n_format)
def process_notification(n_object: NotificationContextData, datastore): 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
@@ -76,6 +120,15 @@ def process_notification(n_object: NotificationContextData, datastore):
# 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
# 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): if not isinstance(n_object, NotificationContextData):
raise TypeError(f"Expected NotificationContextData, got {type(n_object)}") raise TypeError(f"Expected NotificationContextData, got {type(n_object)}")
@@ -86,20 +139,25 @@ def process_notification(n_object: NotificationContextData, datastore):
# 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]).lower() requested_output_format = datastore.data['settings']['application'].get('notification_format', valid_notification_formats[default_notification_format]).lower()
n_format = notification_format_align_with_apprise(n_format=n_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
@@ -116,6 +174,8 @@ def process_notification(n_object: NotificationContextData, datastore):
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)
@@ -123,8 +183,32 @@ def process_notification(n_object: NotificationContextData, datastore):
if n_object.get('markup_text_to_html'): if n_object.get('markup_text_to_html'):
n_body = markup_text_links_to_html(body=n_body) n_body = markup_text_links_to_html(body=n_body)
if n_format == str(NotifyFormat.HTML): # 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
n_body = n_body.replace("\n", '<br>') n_body = n_body.replace("\n", '<br>')
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)
@@ -140,74 +224,28 @@ def process_notification(n_object: NotificationContextData, 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)
# 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)

View File

@@ -324,13 +324,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

@@ -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 REMOVED_STYLE, 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 REMOVED_STYLE in html_content
assert 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

@@ -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

@@ -63,7 +63,7 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
"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",
@@ -177,7 +177,7 @@ def test_check_include_filters_failure_notification(client, live_server, measure
# # live_server_setup(live_server) # Setup on conftest per function # # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('HTML Color')) 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 # 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('Text')) 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

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",
@@ -383,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()
@@ -541,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

@@ -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
@@ -56,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
@@ -66,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