Compare commits

..

3 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
16 changed files with 65 additions and 645 deletions

View File

@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
__version__ = '0.50.29'
__version__ = '0.50.28'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError

View File

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

View File

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

View File

@@ -761,14 +761,6 @@ class commonSettingsForm(Form):
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")])
# 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):
from . import processors

View File

@@ -185,21 +185,8 @@ def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False
tree = html.fromstring(bytes(html_content, encoding='utf-8'), parser=parser)
html_block = ""
# Build namespace map for XPath queries
namespaces = {'re': 'http://exslt.org/regular-expressions'}
# Handle default namespace in documents (common in RSS/Atom feeds, but can occur in any XML)
# XPath spec: unprefixed element names have no namespace, not the default namespace
# Solution: Register the default namespace with empty string prefix in elementpath
# This is primarily for RSS/Atom feeds but works for any XML with default namespace
if hasattr(tree, 'nsmap') and tree.nsmap and None in tree.nsmap:
# Register the default namespace with empty string prefix for elementpath
# This allows //title to match elements in the default namespace
namespaces[''] = tree.nsmap[None]
r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces, parser=XPath3Parser)
#@note: //title/text() now works with default namespaces (fixed by registering '' prefix)
#@note: //title/text() wont work where <title>CDATA.. (use cdata_in_document_to_text first)
r = elementpath.select(tree, xpath_filter.strip(), namespaces={'re': 'http://exslt.org/regular-expressions'}, parser=XPath3Parser)
#@note: //title/text() wont work where <title>CDATA..
if type(r) != list:
r = [r]
@@ -234,19 +221,8 @@ def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=Fals
tree = html.fromstring(bytes(html_content, encoding='utf-8'), parser=parser)
html_block = ""
# Build namespace map for XPath queries
namespaces = {'re': 'http://exslt.org/regular-expressions'}
# NOTE: lxml's native xpath() does NOT support empty string prefix for default namespace
# For documents with default namespace (RSS/Atom feeds), users must use:
# - local-name(): //*[local-name()='title']/text()
# - Or use xpath_filter (not xpath1_filter) which supports default namespaces
# XPath spec: unprefixed element names have no namespace, not the default namespace
r = tree.xpath(xpath_filter.strip(), namespaces=namespaces)
#@note: xpath1 (lxml) does NOT automatically handle default namespaces
#@note: Use //*[local-name()='element'] or switch to xpath_filter for default namespace support
#@note: //title/text() wont work where <title>CDATA.. (use cdata_in_document_to_text first)
r = tree.xpath(xpath_filter.strip(), namespaces={'re': 'http://exslt.org/regular-expressions'})
#@note: //title/text() wont work where <title>CDATA..
for element in r:
# When there's more than 1 match, then add the suffix to separate each line

View File

@@ -9,7 +9,6 @@ from pathlib import Path
from loguru import logger
from .. import jinja2_custom as safe_jinja
from ..diff import ADDED_PLACEMARKER_OPEN
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
# Allowable protocols, protects against javascript: etc

View File

@@ -1,286 +0,0 @@
"""
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

@@ -6,9 +6,6 @@ from loguru import logger
from urllib.parse import urlparse
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
@@ -76,21 +73,17 @@ def notification_format_align_with_apprise(n_format : str):
return n_format
def apply_service_tweaks(url, n_body, n_title, requested_output_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
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 \
if not 'avatar_url' in url \
and not url.startswith('mail') \
and not url.startswith('post') \
and not url.startswith('get') \
@@ -104,88 +97,19 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
# @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'')
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
@@ -195,8 +119,6 @@ def process_notification(n_object: NotificationContextData, datastore):
from . import default_notification_format_for_watch, default_notification_format, valid_notification_formats
# be sure its registered
from .apprise_plugin.custom_handlers import apprise_http_custom_handler
# 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]
@@ -227,8 +149,6 @@ def process_notification(n_object: NotificationContextData, datastore):
# Initially text or whatever
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")
@@ -249,12 +169,6 @@ def process_notification(n_object: NotificationContextData, datastore):
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'):
return None
@@ -281,6 +195,7 @@ def process_notification(n_object: NotificationContextData, datastore):
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>')
if not 'format=' in url.lower():
url = f"{url}{prefix_add_to_url}format={output_format}"
@@ -309,7 +224,7 @@ def process_notification(n_object: NotificationContextData, datastore):
logger.info(f">> Process Notification: AppRise notifying {url}")
url = jinja_render(template_str=url, **notification_parameters)
(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)
(url, n_body, n_title) = apply_service_tweaks(url=url, n_body=n_body, n_title=n_title)
apobj.add(url)

View File

@@ -92,6 +92,7 @@ class NotificationService:
if n_object.get('notification_format') == default_notification_format_for_watch:
n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format')
html_colour_enable = False
# HTML needs linebreak, but MarkDown and Text can use a linefeed
if n_object.get('notification_format') == 'HTML':
line_feed_sep = "<br>"
@@ -101,6 +102,7 @@ class NotificationService:
line_feed_sep = "<br>"
# Snapshot will be plaintext on the disk, convert to some kind of HTML
snapshot_contents = snapshot_contents.replace('\n', line_feed_sep)
html_colour_enable = True
else:
line_feed_sep = "\n"
@@ -121,11 +123,11 @@ class NotificationService:
n_object.update({
'current_snapshot': snapshot_contents,
'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_full': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True, line_feed_sep=line_feed_sep),
'diff': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=line_feed_sep, html_colour=html_colour_enable),
'diff_added': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False, line_feed_sep=line_feed_sep, html_colour=html_colour_enable),
'diff_full': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True, line_feed_sep=line_feed_sep, html_colour=html_colour_enable),
'diff_patch': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=line_feed_sep, patch_format=True),
'diff_removed': diff.render_diff(prev_snapshot, current_snapshot, include_added=False, line_feed_sep=line_feed_sep),
'diff_removed': diff.render_diff(prev_snapshot, current_snapshot, include_added=False, line_feed_sep=line_feed_sep, html_colour=html_colour_enable),
'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None,
'triggered_text': triggered_text,
'uuid': watch.get('uuid') if watch else None,

View File

@@ -7,7 +7,6 @@ import re
import urllib3
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.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE
from changedetectionio import html_tools, content_fetchers

View File

@@ -2,13 +2,6 @@
$(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) {
$('.ajax-op').on('click.socketHandlerNamespace', function (e) {
e.preventDefault();
@@ -108,7 +101,6 @@ $(document).ready(function () {
socket.on('watch_deleted', function (data) {
$('tr[data-watch-uuid="' + data.uuid + '"] td').fadeOut(500, function () {
$(this).closest('tr').remove();
reapplyTableStripes();
});
});

View File

@@ -3,7 +3,7 @@ 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.diff import REMOVED_STYLE, ADDED_STYLE
from changedetectionio.tests.util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, \
wait_for_all_checks, \
set_longer_modified_response, delete_all_watches
@@ -225,8 +225,8 @@ def test_check_notification_html_color_format(client, live_server, measure_memor
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 REMOVED_STYLE in html_content
assert ADDED_STYLE in html_content
assert 'some text<br>' in html_content
delete_all_watches(client)

View File

@@ -6,9 +6,6 @@ from flask import url_for
from .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output, delete_all_watches
import time
from ..diff import ADDED_PLACEMARKER_OPEN
def set_original(excluding=None, add_line=None):
test_return_data = """<html>
<body>
@@ -124,7 +121,6 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
"application-notification_body": 'triggered text was -{{triggered_text}}- ### 网站监测 内容更新了 ####',
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
"application-notification_urls": test_notification_url,
"application-notification_format": 'Plain Text',
"application-minutes_between_check": 180,
"application-fetch_backend": "html_requests"
},
@@ -178,7 +174,6 @@ 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"
with open("test-datastore/notification.txt", 'rb') as f:
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 '网站监测 内容更新了'.encode('utf-8') in response

View File

@@ -485,7 +485,7 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
def _test_color_notifications(client, notification_body_token):
from changedetectionio.diff import HTML_ADDED_STYLE, HTML_REMOVED_STYLE
from changedetectionio.diff import ADDED_STYLE, REMOVED_STYLE
set_original_response()
@@ -533,7 +533,7 @@ def _test_color_notifications(client, notification_body_token):
with open("test-datastore/notification.txt", 'r') as f:
x = f.read()
assert f'<span style="{HTML_REMOVED_STYLE}">Which is across multiple lines' in x
assert f'<span style="{REMOVED_STYLE}">Which is across multiple lines' in x
client.get(

View File

@@ -1,153 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for XPath default namespace handling in RSS/Atom feeds.
Tests the fix for issue where //title/text() returns empty on feeds with default namespaces.
Real-world test data from https://github.com/microsoft/PowerToys/releases.atom
"""
import sys
import os
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import html_tools
# Real-world Atom feed with default namespace from GitHub PowerToys releases
# This is the actual format that was failing before the fix
atom_feed_with_default_ns = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<id>tag:github.com,2008:https://github.com/microsoft/PowerToys/releases</id>
<link type="text/html" rel="alternate" href="https://github.com/microsoft/PowerToys/releases"/>
<link type="application/atom+xml" rel="self" href="https://github.com/microsoft/PowerToys/releases.atom"/>
<title>Release notes from PowerToys</title>
<updated>2025-10-23T08:53:12Z</updated>
<entry>
<id>tag:github.com,2008:Repository/184456251/v0.95.1</id>
<updated>2025-10-24T14:20:14Z</updated>
<link rel="alternate" type="text/html" href="https://github.com/microsoft/PowerToys/releases/tag/v0.95.1"/>
<title>Release 0.95.1</title>
<content type="html">&lt;p&gt;This patch release fixes several important stability issues.&lt;/p&gt;</content>
<author>
<name>Jaylyn-Barbee</name>
</author>
</entry>
<entry>
<id>tag:github.com,2008:Repository/184456251/v0.95.0</id>
<updated>2025-10-17T12:51:21Z</updated>
<link rel="alternate" type="text/html" href="https://github.com/microsoft/PowerToys/releases/tag/v0.95.0"/>
<title>Release v0.95.0</title>
<content type="html">&lt;p&gt;New features, stability, optimization improvements.&lt;/p&gt;</content>
<author>
<name>Jaylyn-Barbee</name>
</author>
</entry>
</feed>"""
# RSS feed without default namespace
rss_feed_no_default_ns = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Channel Title</title>
<description>Channel Description</description>
<item>
<title>Item 1 Title</title>
<description>Item 1 Description</description>
</item>
<item>
<title>Item 2 Title</title>
<description>Item 2 Description</description>
</item>
</channel>
</rss>"""
# RSS 2.0 feed with namespace prefix (not default)
rss_feed_with_ns_prefix = """<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:atom="http://www.w3.org/2005/Atom"
version="2.0">
<channel>
<title>Channel Title</title>
<atom:link href="http://example.com/feed" rel="self" type="application/rss+xml"/>
<item>
<title>Item Title</title>
<dc:creator>Author Name</dc:creator>
</item>
</channel>
</rss>"""
class TestXPathDefaultNamespace:
"""Test XPath queries on feeds with and without default namespaces."""
def test_atom_feed_simple_xpath_with_xpath_filter(self):
"""Test that //title/text() works on Atom feed with default namespace using xpath_filter."""
result = html_tools.xpath_filter('//title/text()', atom_feed_with_default_ns, is_rss=True)
assert 'Release notes from PowerToys' in result
assert 'Release 0.95.1' in result
assert 'Release v0.95.0' in result
def test_atom_feed_nested_xpath_with_xpath_filter(self):
"""Test nested XPath like //entry/title/text() on Atom feed."""
result = html_tools.xpath_filter('//entry/title/text()', atom_feed_with_default_ns, is_rss=True)
assert 'Release 0.95.1' in result
assert 'Release v0.95.0' in result
# Should NOT include the feed title
assert 'Release notes from PowerToys' not in result
def test_atom_feed_other_elements_with_xpath_filter(self):
"""Test that other elements like //updated/text() work on Atom feed."""
result = html_tools.xpath_filter('//updated/text()', atom_feed_with_default_ns, is_rss=True)
assert '2025-10-23T08:53:12Z' in result
assert '2025-10-24T14:20:14Z' in result
def test_rss_feed_without_namespace(self):
"""Test that //title/text() works on RSS feed without default namespace."""
result = html_tools.xpath_filter('//title/text()', rss_feed_no_default_ns, is_rss=True)
assert 'Channel Title' in result
assert 'Item 1 Title' in result
assert 'Item 2 Title' in result
def test_rss_feed_nested_xpath(self):
"""Test nested XPath on RSS feed without default namespace."""
result = html_tools.xpath_filter('//item/title/text()', rss_feed_no_default_ns, is_rss=True)
assert 'Item 1 Title' in result
assert 'Item 2 Title' in result
# Should NOT include channel title
assert 'Channel Title' not in result
def test_rss_feed_with_prefixed_namespaces(self):
"""Test that feeds with namespace prefixes (not default) still work."""
result = html_tools.xpath_filter('//title/text()', rss_feed_with_ns_prefix, is_rss=True)
assert 'Channel Title' in result
assert 'Item Title' in result
def test_local_name_workaround_still_works(self):
"""Test that local-name() workaround still works for Atom feeds."""
result = html_tools.xpath_filter('//*[local-name()="title"]/text()', atom_feed_with_default_ns, is_rss=True)
assert 'Release notes from PowerToys' in result
assert 'Release 0.95.1' in result
def test_xpath1_filter_without_default_namespace(self):
"""Test xpath1_filter works on RSS without default namespace."""
result = html_tools.xpath1_filter('//title/text()', rss_feed_no_default_ns, is_rss=True)
assert 'Channel Title' in result
assert 'Item 1 Title' in result
def test_xpath1_filter_with_default_namespace_returns_empty(self):
"""Test that xpath1_filter returns empty on Atom with default namespace (known limitation)."""
result = html_tools.xpath1_filter('//title/text()', atom_feed_with_default_ns, is_rss=True)
# xpath1_filter (lxml) doesn't support default namespaces, so this returns empty
assert result == ''
def test_xpath1_filter_local_name_workaround(self):
"""Test that xpath1_filter works with local-name() workaround on Atom feeds."""
result = html_tools.xpath1_filter('//*[local-name()="title"]/text()', atom_feed_with_default_ns, is_rss=True)
assert 'Release notes from PowerToys' in result
assert 'Release 0.95.1' in result
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

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