Compare commits

...

20 Commits

Author SHA1 Message Date
dgtlmoon
4b14cec5f4 Real time UI - Remove polling thread for updates - it's all done realtime by signals (#3603)
Some checks are pending
Build and push containers / metadata (push) Waiting to run
Build and push containers / build-push-containers (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Blocked by required conditions
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Blocked by required conditions
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / test-application-3-10 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-11 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-12 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-13 (push) Blocked by required conditions
2025-11-05 21:49:17 +01:00
dgtlmoon
a8d5ea067d Watch history - Don't rescan whole history.txt when looking up a timestamp <->filepath (#3602) 2025-11-05 18:50:27 +01:00
dgtlmoon
2f6873f7d5 Datastore - Use orjson for faster saves (#3601)
Some checks failed
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Waiting to run
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Waiting to run
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Waiting to run
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Waiting to run
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Waiting to run
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Waiting to run
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-11-05 15:12:11 +01:00
dgtlmoon
dfa85ab932 Scheduler - Saving a couple of CPU cycles in logging strategy
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-11-03 19:22:24 +01:00
dgtlmoon
72ec8d8aaa 0.50.39 2025-11-03 18:57:53 +01:00
dgtlmoon
ee824c856b Time scheduler - Remove cache on time lookup 2025-11-03 18:56:37 +01:00
dgtlmoon
c2ee1c582a Tests - Adding extra placemarker tests (#3592 #3591 )
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-11-03 13:28:19 +01:00
dependabot[bot]
374649cc10 Update jsonpath-ng requirement from ~=1.5.3 to ~=1.7.0 (#3586) 2025-11-03 10:01:44 +01:00
dependabot[bot]
f638bc6332 Bump actions/download-artifact from 5 to 6 in the all group (#3585) 2025-11-03 09:59:26 +01:00
dependabot[bot]
5742ad6fab Update pytest-flask requirement from ~=1.2 to ~=1.3 (#3587) 2025-11-03 09:58:16 +01:00
dependabot[bot]
0022201918 Update python-socketio requirement from ~=5.14.2 to ~=5.14.3 (#3588) 2025-11-03 09:57:57 +01:00
dgtlmoon
2e4f40b172 API - Adding better explanation and usage of History API, bumping doc versions.
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-31 00:41:09 +01:00
dgtlmoon
80b614afa1 API - Rebuilding HTML docs
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-30 17:57:25 +01:00
dgtlmoon
d18029ffe4 API - Support optional processor on Watch create to set the restock_diff or text_json_diff mode on watch create. 2025-10-30 17:55:39 +01:00
dgtlmoon
9a44509134 Notifications - Adding {{diff_full_clean}}, {{diff_removed_clean}}, {{diff_added_clean}}, {{diff_clean}} notification body tokens for using in templates without (added)/(removed) text. (#3580) 2025-10-30 17:22:48 +01:00
dgtlmoon
33ab4c8891 0.50.38
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-30 14:38:14 +01:00
dgtlmoon
e1028f822d Improved send test notification handling (#3579) 2025-10-30 14:37:57 +01:00
dgtlmoon
ae1cd61e61 0.50.37 2025-10-30 13:48:41 +01:00
dgtlmoon
a5fe1a771f Fixing title markup in notifications (title/subject for email, slack etc), refactoring line-feed logic \n -> <br> etc (#3577) #3538 #3576 2025-10-30 13:41:50 +01:00
dgtlmoon
b0980f45b8 Dockerfile cache tweaks and build layer github cache re-enable (#3575) 2025-10-30 12:18:12 +01:00
30 changed files with 458 additions and 287 deletions

View File

@@ -82,5 +82,5 @@ jobs:
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
cache-from: type=gha
cache-to: type=gha,mode=min
cache-to: type=gha,mode=max

View File

@@ -69,7 +69,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download Docker image artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -96,7 +96,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download Docker image artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -135,7 +135,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download Docker image artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -177,7 +177,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download Docker image artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -217,7 +217,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download Docker image artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -253,7 +253,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download Docker image artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -282,7 +282,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download Docker image artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -322,7 +322,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download Docker image artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -353,7 +353,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download Docker image artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -398,7 +398,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download Docker image artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp

View File

@@ -34,25 +34,27 @@ ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabihf"
ENV OPENSSL_INCLUDE_DIR="/usr/include/openssl"
# Additional environment variables for cryptography Rust build
ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1
RUN --mount=type=cache,target=/tmp/pip-cache \
pip install \
--prefer-binary \
--extra-index-url https://www.piwheels.org/simple \
--extra-index-url https://pypi.anaconda.org/ARM-software/simple \
--cache-dir=/tmp/pip-cache \
--target=/dependencies \
-r /requirements.txt
RUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \
pip install \
--prefer-binary \
--extra-index-url https://www.piwheels.org/simple \
--extra-index-url https://pypi.anaconda.org/ARM-software/simple \
--cache-dir=/tmp/pip-cache \
--target=/dependencies \
-r /requirements.txt
# Playwright is an alternative to Selenium
# Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing
# https://github.com/dgtlmoon/changedetection.io/pull/1067 also musl/alpine (not supported)
RUN --mount=type=cache,target=/tmp/pip-cache \
pip install \
--prefer-binary \
--cache-dir=/tmp/pip-cache \
--target=/dependencies \
playwright~=1.48.0 \
|| echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled."
RUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \
pip install \
--prefer-binary \
--cache-dir=/tmp/pip-cache \
--target=/dependencies \
playwright~=1.48.0 \
|| echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled."
# Final image stage
FROM python:${PYTHON_VERSION}-slim-bookworm

View File

@@ -64,7 +64,7 @@ def count_words_in_history(watch):
return 0
latest_key = list(watch.history.keys())[-1]
latest_content = watch.get_history_snapshot(latest_key)
latest_content = watch.get_history_snapshot(timestamp=latest_key)
return len(latest_content.split())
except Exception as e:
logger.error(f"Error counting words: {str(e)}")

View File

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

View File

@@ -175,7 +175,7 @@ class WatchSingleHistory(Resource):
response = make_response("No content found", 404)
response.mimetype = "text/plain"
else:
content = watch.get_history_snapshot(timestamp)
content = watch.get_history_snapshot(timestamp=timestamp)
response = make_response(content, 200)
response.mimetype = "text/plain"

View File

@@ -96,7 +96,10 @@ def build_watch_json_schema(d):
"enum": ["html_requests", "html_webdriver"]
})
schema['properties']['processor'] = {"anyOf": [
{"type": "string", "enum": ["restock_diff", "text_json_diff"]},
{"type": "null"}
]}
# All headers must be key/value type dict
schema['properties']['headers'] = {

View File

@@ -118,8 +118,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
fe.title(title=watch_label)
try:
html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]),
newest_version_file_contents=watch.get_history_snapshot(dates[-1]),
html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(timestamp=dates[-2]),
newest_version_file_contents=watch.get_history_snapshot(timestamp=dates[-1]),
include_equal=False,
line_feed_sep="<br>"
)

View File

@@ -2,7 +2,7 @@ from flask import Blueprint, request, make_response
import random
from loguru import logger
from changedetectionio.notification_service import NotificationContextData
from changedetectionio.notification_service import NotificationContextData, set_basic_notification_vars
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
@@ -95,7 +95,44 @@ def construct_blueprint(datastore: ChangeDetectionStore):
n_object['notification_body'] = "Test body"
n_object['as_async'] = False
n_object.update(watch.extra_notification_token_values())
# Same like in notification service, should be refactored
dates = []
trigger_text = ''
snapshot_contents = ''
if watch:
watch_history = watch.history
dates = list(watch_history.keys())
trigger_text = watch.get('trigger_text', [])
# Add text that was triggered
if len(dates):
snapshot_contents = watch.get_history_snapshot(timestamp=dates[-1])
else:
snapshot_contents = "No snapshot/history available, the watch should fetch atleast once."
if len(trigger_text):
from . import html_tools
triggered_text = html_tools.get_triggered_text(content=snapshot_contents, trigger_text=trigger_text)
if triggered_text:
triggered_text = '\n'.join(triggered_text)
# Could be called as a 'test notification' with only 1 snapshot available
prev_snapshot = "Example text: example test\nExample text: change detection is cool\nExample text: some more examples\n"
current_snapshot = "Example text: example test\nExample text: change detection is fantastic\nExample text: even more examples\nExample text: a lot more examples"
if len(dates) > 1:
prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2])
current_snapshot = watch.get_history_snapshot(timestamp=dates[-1])
n_object.update(set_basic_notification_vars(snapshot_contents=snapshot_contents,
current_snapshot=current_snapshot,
prev_snapshot=prev_snapshot,
watch=watch,
triggered_text=trigger_text))
sent_obj = process_notification(n_object, datastore)
except Exception as e:

View File

@@ -47,7 +47,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
try:
versions = list(watch.history.keys())
content = watch.get_history_snapshot(timestamp)
content = watch.get_history_snapshot(timestamp=timestamp)
triggered_line_numbers = html_tools.strip_ignore_text(content=content,
wordlist=watch['trigger_text'],

View File

@@ -14,7 +14,7 @@ def count_words_in_history(watch, incoming_text=None):
elif watch.history.keys():
# When called from UI extras to count latest snapshot
latest_key = list(watch.history.keys())[-1]
latest_content = watch.get_history_snapshot(latest_key)
latest_content = watch.get_history_snapshot(timestamp=latest_key)
return len(latest_content.split())
return 0
except Exception as e:

View File

@@ -794,15 +794,19 @@ def ticker_thread_check_time_launch_checks():
# @todo - Maybe make this a hook?
# Time schedule limit - Decide between watch or global settings
scheduler_source = None
if watch.get('time_between_check_use_default'):
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})
logger.trace(f"{uuid} Time scheduler - Using system/global settings")
scheduler_source = 'system/global settings'
else:
time_schedule_limit = watch.get('time_schedule_limit')
logger.trace(f"{uuid} Time scheduler - Using watch settings (not global settings)")
scheduler_source = 'watch'
tz_name = datastore.data['settings']['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC').strip())
if time_schedule_limit and time_schedule_limit.get('enabled'):
logger.trace(f"{uuid} Time scheduler - Using scheduler settings from {scheduler_source}")
try:
result = is_within_schedule(time_schedule_limit=time_schedule_limit,
default_tz=tz_name
@@ -814,6 +818,7 @@ def ticker_thread_check_time_launch_checks():
logger.error(
f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}")
return False
# If they supplied an individual entry minutes to threshold.
threshold = recheck_time_system_seconds if watch.get('time_between_check_use_default') else watch.threshold_seconds()

View File

@@ -276,9 +276,17 @@ class model(watch_base):
# When the 'last viewed' timestamp is less than the oldest snapshot, return oldest
return sorted_keys[-1]
def get_history_snapshot(self, timestamp):
def get_history_snapshot(self, timestamp=None, filepath=None):
"""
Accepts either timestamp or filepath
:param timestamp:
:param filepath:
:return:
"""
import brotli
filepath = self.history[timestamp]
if not filepath:
filepath = self.history[timestamp]
# See if a brotli versions exists and switch to that
if not filepath.endswith('.br') and os.path.isfile(f"{filepath}.br"):
@@ -382,7 +390,7 @@ class model(watch_base):
# Compare each lines (set) against each history text file (set) looking for something new..
existing_history = set({})
for k, v in self.history.items():
content = self.get_history_snapshot(k)
content = self.get_history_snapshot(filepath=v)
if ignore_whitespace:
alist = set([line.translate(TRANSLATE_WHITESPACE_TABLE).lower() for line in content.splitlines()])
@@ -639,7 +647,7 @@ class model(watch_base):
for k, fname in self.history.items():
if os.path.isfile(fname):
if True:
contents = self.get_history_snapshot(k)
contents = self.get_history_snapshot(timestamp=k)
res = re.findall(regex, contents, re.MULTILINE)
if res:
if not csv_writer:
@@ -732,7 +740,7 @@ class model(watch_base):
# If a previous attempt doesnt yet exist, just snarf the previous snapshot instead
dates = list(self.history.keys())
if len(dates):
return self.get_history_snapshot(dates[-1])
return self.get_history_snapshot(timestamp=dates[-1])
else:
return ''

View File

@@ -5,13 +5,15 @@ from apprise import NotifyFormat
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 .email_helpers import as_monospaced_html_email
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, HTML_CHANGED_STYLE, HTML_CHANGED_INTO_STYLE
from ..notification_service import NotificationContextData, CUSTOM_LINEBREAK_PLACEHOLDER
import re
from ..notification_service import NotificationContextData
newline_re = re.compile(r'\r\n|\r|\n')
def markup_text_links_to_html(body):
@@ -127,6 +129,62 @@ def apply_standard_markdown_to_body(n_body):
return n_body
def replace_placemarkers_in_text(text, url, requested_output_format):
"""
Replace diff placemarkers in text based on the URL service type and requested output format.
Used for both notification title and body to ensure consistent placeholder replacement.
:param text: The text to process
:param url: The notification URL (to detect service type)
:param requested_output_format: The output format (html, htmlcolor, markdown, text, etc.)
:return: Processed text with placemarkers replaced
"""
if not text:
return text
if url.startswith('tgram://'):
# Telegram only supports a limited subset of HTML
# Use strikethrough for removed content, bold for added content
text = text.replace(REMOVED_PLACEMARKER_OPEN, '<s>')
text = text.replace(REMOVED_PLACEMARKER_CLOSED, '</s>')
text = text.replace(ADDED_PLACEMARKER_OPEN, '<b>')
text = text.replace(ADDED_PLACEMARKER_CLOSED, '</b>')
# Handle changed/replaced lines (old → new)
text = text.replace(CHANGED_PLACEMARKER_OPEN, '<s>')
text = text.replace(CHANGED_PLACEMARKER_CLOSED, '</s>')
text = text.replace(CHANGED_INTO_PLACEMARKER_OPEN, '<b>')
text = text.replace(CHANGED_INTO_PLACEMARKER_CLOSED, '</b>')
elif (url.startswith('discord://') or url.startswith('https://discordapp.com/api/webhooks')
or url.startswith('https://discord.com/api')) and requested_output_format == 'html':
# Discord doesn't support HTML, use Discord markdown
text = apply_discord_markdown_to_body(n_body=text)
elif requested_output_format == 'htmlcolor':
# https://github.com/dgtlmoon/changedetection.io/issues/821#issuecomment-1241837050
text = text.replace(REMOVED_PLACEMARKER_OPEN, f'<span style="{HTML_REMOVED_STYLE}" role="deletion" aria-label="Removed text" title="Removed text">')
text = text.replace(REMOVED_PLACEMARKER_CLOSED, f'</span>')
text = text.replace(ADDED_PLACEMARKER_OPEN, f'<span style="{HTML_ADDED_STYLE}" role="insertion" aria-label="Added text" title="Added text">')
text = text.replace(ADDED_PLACEMARKER_CLOSED, f'</span>')
# Handle changed/replaced lines (old → new)
text = text.replace(CHANGED_PLACEMARKER_OPEN, f'<span style="{HTML_CHANGED_STYLE}" role="note" aria-label="Changed text" title="Changed text">')
text = text.replace(CHANGED_PLACEMARKER_CLOSED, f'</span>')
text = text.replace(CHANGED_INTO_PLACEMARKER_OPEN, f'<span style="{HTML_CHANGED_INTO_STYLE}" role="note" aria-label="Changed into" title="Changed into">')
text = text.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'</span>')
elif requested_output_format == 'markdown':
# Markdown to HTML - Apprise will convert this to HTML
text = apply_standard_markdown_to_body(n_body=text)
else:
# plaintext, html, and default - use simple text markers
text = text.replace(REMOVED_PLACEMARKER_OPEN, '(removed) ')
text = text.replace(REMOVED_PLACEMARKER_CLOSED, '')
text = text.replace(ADDED_PLACEMARKER_OPEN, '(added) ')
text = text.replace(ADDED_PLACEMARKER_CLOSED, '')
text = text.replace(CHANGED_PLACEMARKER_OPEN, f'(changed) ')
text = text.replace(CHANGED_PLACEMARKER_CLOSED, f'')
text = text.replace(CHANGED_INTO_PLACEMARKER_OPEN, f'(into) ')
text = text.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'')
return text
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.
@@ -138,6 +196,12 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
if not n_body or not n_body.strip():
return url, n_body, n_title
# Normalize URL scheme to lowercase to prevent case-sensitivity issues
# e.g., "Discord://webhook" -> "discord://webhook", "TGRAM://bot123" -> "tgram://bot123"
scheme_separator_pos = url.find('://')
if scheme_separator_pos > 0:
url = url[:scheme_separator_pos].lower() + url[scheme_separator_pos:]
# 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 '&'
@@ -149,24 +213,22 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
and not url.startswith('put'):
url += k + f"avatar_url={APPRISE_AVATAR_URL}"
# Replace placemarkers in title first (this was the missing piece causing the bug)
# Titles are ALWAYS plain text across all notification services (Discord embeds, Slack attachments,
# email Subject headers, etc.), so we always use 'text' format for title placemarker replacement
# Looking over apprise library it seems that all plugins only expect plain-text.
n_title = replace_placemarkers_in_text(n_title, url, 'text')
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')
n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '\n')
n_body = newline_re.sub('\n', n_body)
# 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>')
# Replace placemarkers for body
n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)
# real limit is 4096, but minus some for extra metadata
payload_max_size = 3600
@@ -180,7 +242,7 @@ def apply_service_tweaks(url, n_body, n_title, 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')
n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '\n')
n_body = newline_re.sub('\n', n_body)
# 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,
@@ -190,7 +252,7 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
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 = apply_discord_markdown_to_body(n_body=n_body)
n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)
# Apply 2000 char limit for plain content
payload_max_size = 1700
@@ -201,40 +263,17 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
# Is not discord/tgram and they want htmlcolor
elif requested_output_format == 'htmlcolor':
# https://github.com/dgtlmoon/changedetection.io/issues/821#issuecomment-1241837050
n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN, f'<span style="{HTML_REMOVED_STYLE}" role="deletion" aria-label="Removed text" title="Removed text">')
n_body = n_body.replace(REMOVED_PLACEMARKER_CLOSED, f'</span>')
n_body = n_body.replace(ADDED_PLACEMARKER_OPEN, f'<span style="{HTML_ADDED_STYLE}" role="insertion" aria-label="Added text" title="Added text">')
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_CHANGED_STYLE}" role="note" aria-label="Changed text" title="Changed text">')
n_body = n_body.replace(CHANGED_PLACEMARKER_CLOSED, f'</span>')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_OPEN, f'<span style="{HTML_CHANGED_INTO_STYLE}" role="note" aria-label="Changed into" title="Changed into">')
n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'</span>')
n_body = n_body.replace('\n', f'{CUSTOM_LINEBREAK_PLACEHOLDER}\n')
n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)
n_body = newline_re.sub('<br>\n', n_body)
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', f'{CUSTOM_LINEBREAK_PLACEHOLDER}\n')
n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)
n_body = newline_re.sub('<br>\n', n_body)
elif requested_output_format == 'markdown':
# Markdown to HTML - Apprise will convert this to HTML
n_body = apply_standard_markdown_to_body(n_body=n_body)
n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)
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'')
n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)
return url, n_body, n_title
@@ -295,24 +334,18 @@ def process_notification(n_object: NotificationContextData, datastore):
with (apprise.LogCapture(level=apprise.logging.DEBUG) as logs):
for url in n_object['notification_urls']:
# Get the notification body from datastore
n_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters)
n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)
if n_object.get('markup_text_links_to_html_links'):
n_body = markup_text_links_to_html(body=n_body)
n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)
url = url.strip()
if url.startswith('#'):
logger.trace(f"Skipping commented out notification URL - {url}")
if not url or url.startswith('#'):
logger.debug(f"Skipping commented out or empty notification URL - '{url}'")
continue
if not url:
logger.warning(f"Process Notification: skipping empty notification URL.")
continue
logger.info(f">> Process Notification: AppRise notifying {url}")
logger.info(f">> Process Notification: AppRise start notifying '{url}'")
url = jinja_render(template_str=url, **notification_parameters)
# If it's a plaintext document, and they want HTML type email/alerts, so it needs to be escaped
@@ -353,25 +386,18 @@ def process_notification(n_object: NotificationContextData, datastore):
requested_output_format = NotifyFormat.HTML.value
apprise_input_format = NotifyFormat.HTML.value # Changed from MARKDOWN to HTML
# Could have arrived at any stage, so we dont end up running .escape on it
if 'html' in requested_output_format:
n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '<br>\r\n')
else:
# texty types
n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '\r\n')
else:
# ?format was IN the apprise URL, they are kind of on their own here, we will try our best
if 'format=html' in url:
n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '<br>\r\n')
n_body = newline_re.sub('<br>\r\n', n_body)
# This will also prevent apprise from doing conversion
apprise_input_format = NotifyFormat.HTML.value
requested_output_format = NotifyFormat.HTML.value
elif 'format=text' in url:
n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '\r\n')
apprise_input_format = NotifyFormat.TEXT.value
requested_output_format = NotifyFormat.TEXT.value
sent_objs.append({'title': n_title,
'body': n_body,
'url': url})

View File

@@ -9,11 +9,8 @@ for both sync and async workers
from loguru import logger
import time
from changedetectionio.model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
from changedetectionio.notification import default_notification_format, valid_notification_formats
# This gets modified on notification time (handler.py) depending on the required notification output
CUSTOM_LINEBREAK_PLACEHOLDER='@BR@'
# What is passed around as notification context, also used as the complete list of valid {{ tokens }}
@@ -23,10 +20,14 @@ class NotificationContextData(dict):
'base_url': None,
'current_snapshot': None,
'diff': None,
'diff_clean': None,
'diff_added': None,
'diff_added_clean': None,
'diff_full': None,
'diff_full_clean': None,
'diff_patch': None,
'diff_removed': None,
'diff_removed_clean': None,
'diff_url': None,
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
'notification_timestamp': time.time(),
@@ -71,6 +72,38 @@ class NotificationContextData(dict):
super().__setitem__(key, value)
def set_basic_notification_vars(snapshot_contents, current_snapshot, prev_snapshot, watch, triggered_text):
now = time.time()
from changedetectionio import diff
n_object = {
'current_snapshot': snapshot_contents,
'diff': diff.render_diff(prev_snapshot, current_snapshot),
'diff_clean': diff.render_diff(prev_snapshot, current_snapshot, include_change_type_prefix=False),
'diff_added': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False),
'diff_added_clean': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False, include_change_type_prefix=False),
'diff_full': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True),
'diff_full_clean': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True, include_change_type_prefix=False),
'diff_patch': diff.render_diff(prev_snapshot, current_snapshot, patch_format=True),
'diff_removed': diff.render_diff(prev_snapshot, current_snapshot, include_added=False),
'diff_removed_clean': diff.render_diff(prev_snapshot, current_snapshot, include_added=False, include_change_type_prefix=False),
'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None,
'triggered_text': triggered_text,
'uuid': watch.get('uuid') if watch else None,
'watch_url': watch.get('url') if watch else None,
'watch_uuid': watch.get('uuid') if watch else None,
'watch_mime_type': watch.get('content-type')
}
# The \n's in the content from the above will get converted to <br> etc depending on the notification format
if watch:
n_object.update(watch.extra_notification_token_values())
logger.trace(f"Main rendered notification placeholders (diff_added etc) calculated in {time.time() - now:.3f}s")
return n_object
class NotificationService:
"""
Standalone notification service that handles all notification functionality
@@ -85,7 +118,6 @@ class NotificationService:
"""
Queue a notification for a watch with full diff rendering and template variables
"""
from changedetectionio import diff
from changedetectionio.notification import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
if not isinstance(n_object, NotificationContextData):
@@ -94,8 +126,6 @@ class NotificationService:
dates = []
trigger_text = ''
now = time.time()
if watch:
watch_history = watch.history
dates = list(watch_history.keys())
@@ -103,7 +133,7 @@ class NotificationService:
# Add text that was triggered
if len(dates):
snapshot_contents = watch.get_history_snapshot(dates[-1])
snapshot_contents = watch.get_history_snapshot(timestamp=dates[-1])
else:
snapshot_contents = "No snapshot/history available, the watch should fetch atleast once."
@@ -117,35 +147,23 @@ class NotificationService:
from . import html_tools
triggered_text = html_tools.get_triggered_text(content=snapshot_contents, trigger_text=trigger_text)
if triggered_text:
triggered_text = CUSTOM_LINEBREAK_PLACEHOLDER.join(triggered_text)
triggered_text = '\n'.join(triggered_text)
# Could be called as a 'test notification' with only 1 snapshot available
prev_snapshot = "Example text: example test\nExample text: change detection is cool\nExample text: some more examples\n"
current_snapshot = "Example text: example test\nExample text: change detection is fantastic\nExample text: even more examples\nExample text: a lot more examples"
if len(dates) > 1:
prev_snapshot = watch.get_history_snapshot(dates[-2])
current_snapshot = watch.get_history_snapshot(dates[-1])
prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2])
current_snapshot = watch.get_history_snapshot(timestamp=dates[-1])
n_object.update({
'current_snapshot': snapshot_contents,
'diff': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=CUSTOM_LINEBREAK_PLACEHOLDER),
'diff_added': diff.render_diff(prev_snapshot, current_snapshot, include_removed=False, line_feed_sep=CUSTOM_LINEBREAK_PLACEHOLDER),
'diff_full': diff.render_diff(prev_snapshot, current_snapshot, include_equal=True, line_feed_sep=CUSTOM_LINEBREAK_PLACEHOLDER),
'diff_patch': diff.render_diff(prev_snapshot, current_snapshot, line_feed_sep=CUSTOM_LINEBREAK_PLACEHOLDER, patch_format=True),
'diff_removed': diff.render_diff(prev_snapshot, current_snapshot, include_added=False, line_feed_sep=CUSTOM_LINEBREAK_PLACEHOLDER),
'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None,
'triggered_text': triggered_text,
'uuid': watch.get('uuid') if watch else None,
'watch_url': watch.get('url') if watch else None,
'watch_uuid': watch.get('uuid') if watch else None,
'watch_mime_type': watch.get('content-type')
})
if watch:
n_object.update(watch.extra_notification_token_values())
n_object.update(set_basic_notification_vars(snapshot_contents=snapshot_contents,
current_snapshot=current_snapshot,
prev_snapshot=prev_snapshot,
watch=watch,
triggered_text=triggered_text))
logger.trace(f"Main rendered notification placeholders (diff_added etc) calculated in {time.time()-now:.3f}s")
logger.debug("Queued notification for sending")
self.notification_q.put(n_object)

View File

@@ -37,18 +37,6 @@ class SignalHandler:
notification_event_signal.connect(self.handle_notification_event, weak=False)
logger.info("SignalHandler: Connected to notification_event signal")
# Create and start the queue update thread using standard threading
import threading
self.polling_emitter_thread = threading.Thread(
target=self.polling_emit_running_or_queued_watches_threaded,
daemon=True
)
self.polling_emitter_thread.start()
logger.info("Started polling thread using threading (eventlet-free)")
# Store the thread reference in socketio for clean shutdown
self.socketio_instance.polling_emitter_thread = self.polling_emitter_thread
def handle_signal(self, *args, **kwargs):
logger.trace(f"SignalHandler: Signal received with {len(args)} args and {len(kwargs)} kwargs")
# Safely extract the watch UUID from kwargs
@@ -124,74 +112,6 @@ class SignalHandler:
except Exception as e:
logger.error(f"Socket.IO error in handle_notification_event: {str(e)}")
def polling_emit_running_or_queued_watches_threaded(self):
"""Threading version of polling for Windows compatibility"""
import time
import threading
logger.info("Queue update thread started (threading mode)")
# Import here to avoid circular imports
from changedetectionio.flask_app import app
from changedetectionio import worker_handler
watch_check_update = signal('watch_check_update')
# Track previous state to avoid unnecessary emissions
previous_running_uuids = set()
# Run until app shutdown - check exit flag more frequently for fast shutdown
exit_event = getattr(app.config, 'exit', threading.Event())
while not exit_event.is_set():
try:
# Get current running UUIDs from async workers
running_uuids = set(worker_handler.get_running_uuids())
# Only send updates for UUIDs that changed state
newly_running = running_uuids - previous_running_uuids
no_longer_running = previous_running_uuids - running_uuids
# Send updates for newly running UUIDs (but exit fast if shutdown requested)
for uuid in newly_running:
if exit_event.is_set():
break
logger.trace(f"Threading polling: UUID {uuid} started processing")
with app.app_context():
watch_check_update.send(app_context=app, watch_uuid=uuid)
time.sleep(0.01) # Small yield
# Send updates for UUIDs that finished processing (but exit fast if shutdown requested)
if not exit_event.is_set():
for uuid in no_longer_running:
if exit_event.is_set():
break
logger.trace(f"Threading polling: UUID {uuid} finished processing")
with app.app_context():
watch_check_update.send(app_context=app, watch_uuid=uuid)
time.sleep(0.01) # Small yield
# Update tracking for next iteration
previous_running_uuids = running_uuids
# Sleep between polling cycles, but check exit flag every 0.5 seconds for fast shutdown
for _ in range(20): # 20 * 0.5 = 10 seconds total
if exit_event.is_set():
break
time.sleep(0.5)
except Exception as e:
logger.error(f"Error in threading polling: {str(e)}")
# Even during error recovery, check for exit quickly
for _ in range(1): # 1 * 0.5 = 0.5 seconds
if exit_event.is_set():
break
time.sleep(0.5)
# Check if we're in pytest environment - if so, be more gentle with logging
import sys
in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
if not in_pytest:
logger.info("Queue update thread stopped (threading mode)")
def handle_watch_update(socketio, **kwargs):
@@ -383,19 +303,6 @@ def init_socketio(app, datastore):
"""Shutdown the SocketIO server fast and aggressively"""
try:
logger.info("Socket.IO: Fast shutdown initiated...")
# For threading mode, give the thread a very short time to exit gracefully
if hasattr(socketio, 'polling_emitter_thread'):
if socketio.polling_emitter_thread.is_alive():
logger.info("Socket.IO: Waiting 1 second for polling thread to stop...")
socketio.polling_emitter_thread.join(timeout=1.0) # Only 1 second timeout
if socketio.polling_emitter_thread.is_alive():
logger.info("Socket.IO: Polling thread still running after timeout - continuing with shutdown")
else:
logger.info("Socket.IO: Polling thread stopped quickly")
else:
logger.info("Socket.IO: Polling thread already stopped")
logger.info("Socket.IO: Fast shutdown complete")
except Exception as e:
logger.error(f"Socket.IO error during shutdown: {str(e)}")

View File

@@ -329,12 +329,18 @@ a.pure-button-selected {
.notifications-wrapper {
padding-top: 0.5rem;
#notification-test-log {
padding-top: 1rem;
margin-top: 1rem;
padding: 1rem;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
box-sizing: border-box;
max-height: 12rem;
overflow-y: scroll;
border: 1px solid var(--color-border-notification);
border-radius: 5px;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -22,6 +22,13 @@ import uuid as uuid_builder
from loguru import logger
from blinker import signal
# Try to import orjson for faster JSON serialization
try:
import orjson
HAS_ORJSON = True
except ImportError:
HAS_ORJSON = False
from .processors import get_custom_watch_obj_for_processor
from .processors.restock_diff import Restock
@@ -426,9 +433,14 @@ class ChangeDetectionStore:
# Re #286 - First write to a temp file, then confirm it looks OK and rename it
# This is a fairly basic strategy to deal with the case that the file is corrupted,
# system was out of memory, out of RAM etc
with open(self.json_store_path+".tmp", 'w') as json_file:
# Use compact JSON in production for better performance
json.dump(data, json_file, indent=2)
if HAS_ORJSON:
# Use orjson for faster serialization
with open(self.json_store_path+".tmp", 'wb') as json_file:
json_file.write(orjson.dumps(data, option=orjson.OPT_INDENT_2))
else:
# Fallback to standard json module
with open(self.json_store_path+".tmp", 'w') as json_file:
json.dump(data, json_file, indent=2)
os.replace(self.json_store_path+".tmp", self.json_store_path)
except Exception as e:
logger.error(f"Error writing JSON!! (Main JSON file save was skipped) : {str(e)}")

View File

@@ -87,19 +87,35 @@
<tr>
<td><code>{{ '{{diff}}' }}</code></td>
<td>The diff output - only changes, additions, and removals</td>
</tr>
<tr>
<td><code>{{ '{{diff_clean}}' }}</code></td>
<td>The diff output - only changes, additions, and removals &dash; <i>Without (added) prefix or colors</i></td>
</tr>
<tr>
<td><code>{{ '{{diff_added}}' }}</code></td>
<td>The diff output - only changes and additions</td>
</tr>
<tr>
<td><code>{{ '{{diff_added_clean}}' }}</code></td>
<td>The diff output - only changes and additions &dash; <i>Without (added) prefix or colors</i></td>
</tr>
<tr>
<td><code>{{ '{{diff_removed}}' }}</code></td>
<td>The diff output - only changes and removals</td>
</tr>
<tr>
<td><code>{{ '{{diff_removed_clean}}' }}</code></td>
<td>The diff output - only changes and removals &dash; <i>Without (added) prefix or colors</i></td>
</tr>
<tr>
<td><code>{{ '{{diff_full}}' }}</code></td>
<td>The diff output - full difference output</td>
</tr>
<tr>
<td><code>{{ '{{diff_full_clean}}' }}</code></td>
<td>The diff output - full difference output &dash; <i>Without (added) prefix or colors</i></td>
</tr>
<tr>
<td><code>{{ '{{diff_patch}}' }}</code></td>
<td>The diff output - patch in unified format</td>

View File

@@ -3,8 +3,9 @@ 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, HTML_CHANGED_STYLE
from changedetectionio.notification_service import NotificationContextData, CUSTOM_LINEBREAK_PLACEHOLDER
from changedetectionio.diff import HTML_REMOVED_STYLE, HTML_ADDED_STYLE, HTML_CHANGED_STYLE, REMOVED_PLACEMARKER_OPEN, \
CHANGED_PLACEMARKER_OPEN, ADDED_PLACEMARKER_OPEN
from changedetectionio.notification_service import NotificationContextData
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
@@ -100,7 +101,6 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas
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
assert CUSTOM_LINEBREAK_PLACEHOLDER not in text_content
# Second part should be text/html
html_part = parts[1]
@@ -109,7 +109,6 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas
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
assert CUSTOM_LINEBREAK_PLACEHOLDER not in html_content
delete_all_watches(client)
@@ -124,8 +123,8 @@ def test_check_notification_plaintext_format(client, live_server, measure_memory
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_title": "fallback-title {{watch_title}} {{ diff_added.splitlines()[0] if diff_added else 'diff added didnt split' }} " + default_notification_title,
"application-notification_body": f"some text\n" + default_notification_body + f"\nMore output test\n{ALL_MARKUP_TOKENS}",
"application-notification_format": 'text',
"requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"},
@@ -148,9 +147,18 @@ def test_check_notification_plaintext_format(client, live_server, measure_memory
msg_raw = get_last_message_from_smtp_server()
assert len(msg_raw) >= 1
#time.sleep(60)
# Parse the email properly using Python's email library
msg = message_from_string(msg_raw, policy=email_policy)
# Subject/title got marked up
subject = msg['subject']
# Subject should always be plaintext and never marked up to anything else
assert REMOVED_PLACEMARKER_OPEN not in subject
assert CHANGED_PLACEMARKER_OPEN not in subject
assert ADDED_PLACEMARKER_OPEN not in subject
assert 'diff added didnt split' not in subject
assert '(changed) Which is across' in subject
assert 'PLACEMARKER' not in subject
# The email should be plain text only (not multipart)
assert not msg.is_multipart()
@@ -177,7 +185,7 @@ def test_check_notification_html_color_format(client, live_server, measure_memor
res = client.post(
url_for("settings.settings_page"),
data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title,
"application-notification_title": "fallback-title {{watch_title}} - diff_added_lines_test : '{{ diff_added.splitlines()[0] if diff_added else 'diff added didnt split' }}' " + default_notification_title,
"application-notification_body": f"some text\n{default_notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}",
"application-notification_format": 'htmlcolor',
"requests-time_between_check-minutes": 180,
@@ -211,6 +219,19 @@ def test_check_notification_html_color_format(client, live_server, measure_memor
# Parse the email properly using Python's email library
msg = message_from_string(msg_raw, policy=email_policy)
# Subject/title got marked up
subject = msg['subject']
# Subject should always be plaintext and never marked up to anything else
assert REMOVED_PLACEMARKER_OPEN not in subject
assert CHANGED_PLACEMARKER_OPEN not in subject
assert ADDED_PLACEMARKER_OPEN not in subject
assert 'diff added didnt split' not in subject
assert '(changed) Which is across' in subject
assert 'PLACEMARKER' not in subject
assert 'head title' in subject
assert "span" not in subject
assert 'background-color' not in subject
# The email should have two bodies (multipart/alternative with text/plain and text/html)
assert msg.is_multipart()
@@ -249,7 +270,7 @@ def test_check_notification_markdown_format(client, live_server, measure_memory_
res = client.post(
url_for("settings.settings_page"),
data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title,
"application-notification_title": "fallback-title diff_added_lines_test : '{{ diff_added.splitlines()[0] if diff_added else 'diff added didnt split' }}' " + default_notification_title,
"application-notification_body": "*header*\n\nsome text\n" + default_notification_body,
"application-notification_format": 'markdown',
"requests-time_between_check-minutes": 180,
@@ -287,6 +308,14 @@ def test_check_notification_markdown_format(client, live_server, measure_memory_
# 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'
subject = msg['subject']
# Subject should always be plaintext and never marked up to anything else
assert REMOVED_PLACEMARKER_OPEN not in subject
assert CHANGED_PLACEMARKER_OPEN not in subject
assert ADDED_PLACEMARKER_OPEN not in subject
assert 'diff added didnt split' not in subject
assert '(changed) Which is across' in subject
# Get the parts
parts = list(msg.iter_parts())
@@ -305,7 +334,10 @@ def test_check_notification_markdown_format(client, live_server, measure_memory_
assert html_part.get_content_type() == 'text/html'
html_content = html_part.get_content()
assert '<p><em>header</em></p>' in html_content
assert '<strong>So let\'s see what happens.</strong><br>' in html_content # Additions are <strong> in markdown
assert '<strong>So let\'s see what happens.</strong><br />' in html_content # Additions are <strong> in markdown
# the '<br />' will come from apprises conversion, not from our code, we would rather use '<br>' correctly
# the '<br />' is actually a nice way to know if apprise done the conversion.
delete_all_watches(client)
# Custom notification body with HTML, that is either sent as HTML or rendered to plaintext and sent
@@ -553,6 +585,7 @@ def test_check_plaintext_document_html_notifications(client, live_server, measur
# Should be the HTML, but not HTML Color
assert 'background-color' not in html_content
assert '<br>(added) And let&#39;s talk about &lt;title&gt; tags<br>' in html_content
assert 'PLACEMARKER' not in html_content
assert '&lt;br' not in html_content
assert '<pre role="article"' in html_content # Should have got wrapped nicely in email_helpers.py
@@ -683,6 +716,7 @@ def test_check_html_document_plaintext_notification(client, live_server, measure
assert '<tag>' in body # Should have got converted from original HTML to plaintext
assert '(changed) some stuff\r\n' in body
assert 'PLACEMARKER' not in body
assert '(into) sxome stuff\r\n' in body
assert '(added) lets slip this in\r\n' in body
assert '(added) and this in\r\n' in body
@@ -752,7 +786,6 @@ def test_check_html_notification_with_apprise_format_is_html(client, live_server
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
assert CUSTOM_LINEBREAK_PLACEHOLDER not in text_content
# Second part should be text/html
html_part = parts[1]
@@ -761,5 +794,4 @@ def test_check_html_notification_with_apprise_format_is_html(client, live_server
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
assert CUSTOM_LINEBREAK_PLACEHOLDER not in html_content
delete_all_watches(client)

View File

@@ -353,7 +353,7 @@ def check_json_ext_filter(json_filter, client, live_server, datastore_path):
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
dates = list(watch.history.keys())
snapshot_contents = watch.get_history_snapshot(dates[0])
snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])
assert snapshot_contents[0] == '['
@@ -439,7 +439,7 @@ def test_correct_header_detect(client, live_server, measure_memory_usage, datast
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
dates = list(watch.history.keys())
snapshot_contents = watch.get_history_snapshot(dates[0])
snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])
assert b'&#34;hello&#34;: 123,' in res.data # properly html escaped in the front end

View File

@@ -404,15 +404,15 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
#2510
#@todo run it again as text, html, htmlcolor
def test_global_send_test_notification(client, live_server, measure_memory_usage, datastore_path):
set_original_response(datastore_path=datastore_path)
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
os.unlink(os.path.join(datastore_path, "notification.txt")) \
# 1995 UTF-8 content should be encoded
test_body = 'change detection is cool 网站监测 内容更新了'
test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}'
# otherwise other settings would have already existed from previous tests in this file
res = client.post(
@@ -452,7 +452,14 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
x = f.read()
assert test_body in x
assert 'change detection is cool 网站监测 内容更新了' in x
if 'html' in default_notification_format:
# this should come from default text when in global/system mode here changedetectionio/notification_service.py
assert 'title="Changed into">Example text:' in x
else:
assert 'title="Changed into">Example text:' not in x
assert 'span' not in x
assert 'Example text:' in x
os.unlink(os.path.join(datastore_path, "notification.txt"))
@@ -509,6 +516,47 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
assert b"Error: You must have atleast one watch configured for 'test notification' to work" in res.data
#2510
def test_single_send_test_notification_on_watch(client, live_server, measure_memory_usage, datastore_path):
set_original_response(datastore_path=datastore_path)
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
os.unlink(os.path.join(datastore_path, "notification.txt")) \
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)
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123"
# 1995 UTF-8 content should be encoded
test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}'
######### Test global/system settings
res = client.post(
url_for("ui.ui_notification.ajax_callback_send_notification_test")+f"/{uuid}",
data={"notification_urls": test_notification_url,
"notification_body": test_body,
"notification_format": default_notification_format,
"notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
},
follow_redirects=True
)
assert res.status_code != 400
assert res.status_code != 500
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
x = f.read()
assert 'change detection is cool 网站监测 内容更新了' in x
if 'html' in default_notification_format:
# this should come from default text when in global/system mode here changedetectionio/notification_service.py
assert 'title="Changed into">Example text:' in x
else:
assert 'title="Changed into">Example text:' not in x
assert 'span' not in x
assert 'Example text:' in x
os.unlink(os.path.join(datastore_path, "notification.txt"))
def _test_color_notifications(client, notification_body_token, datastore_path):

View File

@@ -22,7 +22,7 @@ def test_fetch_pdf(client, live_server, measure_memory_usage, datastore_path):
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
dates = list(watch.history.keys())
snapshot_contents = watch.get_history_snapshot(dates[0])
snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])
# PDF header should not be there (it was converted to text)
assert 'PDF' not in snapshot_contents
@@ -75,7 +75,7 @@ def test_fetch_pdf(client, live_server, measure_memory_usage, datastore_path):
dates = list(watch.history.keys())
# new snapshot was also OK, no HTML
snapshot_contents = watch.get_history_snapshot(dates[1])
snapshot_contents = watch.get_history_snapshot(timestamp=dates[1])
assert 'html' not in snapshot_contents.lower()
assert f'Original file size - {os.path.getsize(os.path.join(datastore_path, "endpoint-test.pdf"))}' in snapshot_contents
assert f'here is a change' in snapshot_contents

View File

@@ -65,7 +65,7 @@ def test_rss_reader_mode(client, live_server, measure_memory_usage, datastore_pa
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
dates = list(watch.history.keys())
snapshot_contents = watch.get_history_snapshot(dates[0])
snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])
assert 'Wet noodles escape' in snapshot_contents
assert '<br>' not in snapshot_contents
assert '&lt;' not in snapshot_contents
@@ -91,7 +91,7 @@ def test_rss_reader_mode_with_css_filters(client, live_server, measure_memory_us
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
dates = list(watch.history.keys())
snapshot_contents = watch.get_history_snapshot(dates[0])
snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])
assert 'Wet noodles escape' not in snapshot_contents
assert '<br>' not in snapshot_contents
assert '&lt;' not in snapshot_contents

View File

@@ -55,8 +55,8 @@ class TestTriggerConditions(unittest.TestCase):
self.assertEqual(len(history), 2)
# Retrieve and check snapshots
#snapshot1 = watch.get_history_snapshot(str(timestamp1))
#snapshot2 = watch.get_history_snapshot(str(timestamp2))
#snapshot1 = watch.get_history_snapshot(timestamp=str(timestamp1))
#snapshot2 = watch.get_history_snapshot(timestamp=str(timestamp2))
self.store.data['watching'][self.watch_uuid].update(
{

View File

@@ -14,7 +14,6 @@ class Weekday(IntEnum):
Saturday = 5
Sunday = 6
@lru_cache(maxsize=100)
def am_i_inside_time(
day_of_week: str,
time_str: str,

View File

@@ -28,7 +28,7 @@ info:
For example: `x-api-key: YOUR_API_KEY`
version: 0.1.2
version: 0.1.3
contact:
name: ChangeDetection.io
url: https://github.com/dgtlmoon/changedetection.io
@@ -65,13 +65,17 @@ tags:
- name: Watch History
description: |
Access historical snapshots and change data for your watches. View the complete timeline of detected changes
and retrieve specific versions of monitored content for comparison and analysis.
Get a list of timestamps of all changes detected for a watch.
- name: Snapshots
description: |
Retrieve individual snapshots of monitored content. Access both the processed change detection data and
the raw HTML content that was captured during monitoring checks.
Retrieve individual text snapshot of monitored content according to the `timestamp`. The text snapshot is the HTML
to Text at page check time.
Set the query argument `html` to any value to retrieve the last HTML fetched, the system only keeps the last two
(2) HTML files fetched.
Use the Watch History API endpoint to get a list of timestamps to pass to this query.
- name: Favicon
description: |
@@ -228,6 +232,11 @@ components:
maxLength: 5000
required: [operation, selector, optional_value]
description: Browser automation steps
processor:
type: string
enum: [restock_diff, text_json_diff]
default: text_json_diff
description: Optional processor mode to use for change detection. Defaults to `text_json_diff` if not specified.
Watch:
allOf:
@@ -428,7 +437,15 @@ paths:
operationId: createWatch
tags: [Watch Management]
summary: Create a new watch
description: Create a single web page change monitor (watch). Requires at least 'url' to be set.
description: |
Create a single web page change monitor (watch). Requires at least `url` to be set.
Every watch can be configured with:
- **Processor mode**: `processor` field (`restock_diff` or `text_json_diff` - default)
- **Notification settings**: `notification_urls` (array), `notification_title`, `notification_body`, `notification_format`, `notification_muted`
- **Tags/Groups**: `tag` (UUID string) or `tags` (array of UUIDs)
- **Check settings**: `time_between_check`, `paused`, `method`, `fetch_backend`
- **Advanced options**: `headers`, `body`, `proxy`, `browser_steps`, and more
x-code-samples:
- lang: 'curl'
source: |
@@ -446,7 +463,7 @@ paths:
source: |
import requests
import json
headers = {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
@@ -458,7 +475,7 @@ paths:
'hours': 1
}
}
response = requests.post('http://localhost:5000/api/v1/watch',
response = requests.post('http://localhost:5000/api/v1/watch',
headers=headers, json=data)
print(response.text)
requestBody:
@@ -648,7 +665,9 @@ paths:
operationId: getWatchHistory
tags: [Watch History]
summary: Get watch history
description: Get a list of all historical snapshots available for a web page change monitor (watch)
description: |
Get a list of all historical snapshots available for a web page change monitor (watch), use the key `timestamp`
as the query argument for fetching a single watch history snapshot.
x-code-samples:
- lang: 'curl'
source: |
@@ -688,7 +707,9 @@ paths:
operationId: getWatchSnapshot
tags: [Snapshots]
summary: Get single snapshot
description: Get single snapshot from web page change monitor (watch). Use 'latest' for the most recent snapshot.
description: |
Get single snapshot from web page change monitor (watch). Use 'latest' for the most recent snapshot.
Use the Watch History API to get a list of timestamps to pass.
x-code-samples:
- lang: 'curl'
source: |

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,7 @@ janus # Thread-safe async/sync queue bridge
flask_wtf~=1.2
flask~=3.1
flask-socketio~=5.5.1
python-socketio~=5.14.2
python-socketio~=5.14.3
python-engineio~=4.12.3
inscriptis~=2.2
pytz
@@ -31,7 +31,10 @@ requests-file
chardet>2.3.0
wtforms~=3.2
jsonpath-ng~=1.5.3
jsonpath-ng~=1.7.0
# Fast JSON serialization for better performance
orjson~=3.10
# dnspython - Used by paho-mqtt for MQTT broker resolution
# Version pin removed since eventlet (which required the specific 2.6.1 pin) has been eliminated
@@ -87,7 +90,7 @@ pyppeteerstealth>=0.0.4
# Include pytest, so if theres a support issue we can ask them to run these tests on their setup
pytest ~=7.2
pytest-flask ~=1.2
pytest-flask ~=1.3
pytest-mock ~=3.15
# Anything 4.0 and up but not 5.0