mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-01 15:17:20 +00:00
Compare commits
11 Commits
HTML-notif
...
openapi-sp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b18aa2350f | ||
|
|
3ffa0805e9 | ||
|
|
3335270692 | ||
|
|
a7573b10ec | ||
|
|
df945ad743 | ||
|
|
4536e95205 | ||
|
|
1479d7bd46 | ||
|
|
9ba2094f75 | ||
|
|
8aa012ba8e | ||
|
|
8bc6b10db1 | ||
|
|
76d799c95b |
6
.github/workflows/pypi-release.yml
vendored
6
.github/workflows/pypi-release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: python3 -m build
|
||||
- name: Store the distribution packages
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
|
||||
- name: Store everything including test-datastore
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }}
|
||||
path: .
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||
|
||||
__version__ = '0.50.31'
|
||||
__version__ = '0.50.32'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -3,15 +3,30 @@ from changedetectionio.strtobool import strtobool
|
||||
from flask_restful import abort, Resource
|
||||
from flask import request
|
||||
import validators
|
||||
from functools import wraps
|
||||
from . import auth, validate_openapi_request
|
||||
|
||||
|
||||
def default_content_type(content_type='text/plain'):
|
||||
"""Decorator to set a default Content-Type header if none is provided."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not request.content_type:
|
||||
# Set default content type in the request environment
|
||||
request.environ['CONTENT_TYPE'] = content_type
|
||||
return f(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class Import(Resource):
|
||||
def __init__(self, **kwargs):
|
||||
# datastore is a black box dependency
|
||||
self.datastore = kwargs['datastore']
|
||||
|
||||
@auth.check_token
|
||||
@default_content_type('text/plain') #3547 #3542
|
||||
@validate_openapi_request('importWatches')
|
||||
def post(self):
|
||||
"""Import a list of watched URLs."""
|
||||
|
||||
42
changedetectionio/notification/email_helpers.py
Normal file
42
changedetectionio/notification/email_helpers.py
Normal file
@@ -0,0 +1,42 @@
|
||||
def as_monospaced_html_email(content: str, title: str) -> str:
|
||||
"""
|
||||
Wraps `content` in a minimal, email-safe HTML template
|
||||
that forces monospace rendering across Gmail, Hotmail, Apple Mail, etc.
|
||||
|
||||
Args:
|
||||
content: The body text (plain text or HTML-like).
|
||||
title: The title plaintext
|
||||
Returns:
|
||||
A complete HTML document string suitable for sending as an email body.
|
||||
"""
|
||||
|
||||
# All line feed types should be removed and then this function should only be fed <br>'s
|
||||
# Then it works with our <pre> styling without double linefeeds
|
||||
content = content.translate(str.maketrans('', '', '\r\n'))
|
||||
|
||||
if title:
|
||||
import html
|
||||
title = html.escape(title)
|
||||
else:
|
||||
title = ''
|
||||
# 2. Full email-safe HTML
|
||||
html_email = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!--[if mso]>
|
||||
<style>
|
||||
body, div, pre, td {{ font-family: "Courier New", Courier, monospace !important; }}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body style="-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
<pre role="article" aria-roledescription="email" lang="en"
|
||||
style="font-family: monospace, 'Courier New', Courier; font-size: 0.8em;
|
||||
white-space: pre-wrap; word-break: break-word;">{content}</pre>
|
||||
</body>
|
||||
</html>"""
|
||||
return html_email
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -76,6 +77,55 @@ def notification_format_align_with_apprise(n_format : str):
|
||||
|
||||
return n_format
|
||||
|
||||
def apply_discord_markdown_to_body(n_body):
|
||||
"""
|
||||
Discord does not support <del> but it supports non-standard ~~strikethrough~~
|
||||
:param n_body:
|
||||
:return:
|
||||
"""
|
||||
import re
|
||||
# Define the mapping between your placeholders and markdown markers
|
||||
replacements = [
|
||||
(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, '**'),
|
||||
]
|
||||
# So that the markdown gets added without any whitespace following it which would break it
|
||||
for open_tag, open_md, close_tag, close_md in replacements:
|
||||
# Regex: match opening tag, optional whitespace, capture the content, optional whitespace, then closing tag
|
||||
pattern = re.compile(
|
||||
re.escape(open_tag) + r'(\s*)(.*?)?(\s*)' + re.escape(close_tag),
|
||||
flags=re.DOTALL
|
||||
)
|
||||
n_body = pattern.sub(lambda m: f"{m.group(1)}{open_md}{m.group(2)}{close_md}{m.group(3)}", n_body)
|
||||
return n_body
|
||||
|
||||
def apply_standard_markdown_to_body(n_body):
|
||||
"""
|
||||
Apprise does not support ~~strikethrough~~ but it will convert <del> to HTML strikethrough.
|
||||
:param n_body:
|
||||
:return:
|
||||
"""
|
||||
import re
|
||||
# Define the mapping between your placeholders and markdown markers
|
||||
replacements = [
|
||||
(REMOVED_PLACEMARKER_OPEN, '<del>', REMOVED_PLACEMARKER_CLOSED, '</del>'),
|
||||
(ADDED_PLACEMARKER_OPEN, '**', ADDED_PLACEMARKER_CLOSED, '**'),
|
||||
(CHANGED_PLACEMARKER_OPEN, '<del>', CHANGED_PLACEMARKER_CLOSED, '</del>'),
|
||||
(CHANGED_INTO_PLACEMARKER_OPEN, '**', CHANGED_INTO_PLACEMARKER_CLOSED, '**'),
|
||||
]
|
||||
|
||||
# So that the markdown gets added without any whitespace following it which would break it
|
||||
for open_tag, open_md, close_tag, close_md in replacements:
|
||||
# Regex: match opening tag, optional whitespace, capture the content, optional whitespace, then closing tag
|
||||
pattern = re.compile(
|
||||
re.escape(open_tag) + r'(\s*)(.*?)?(\s*)' + re.escape(close_tag),
|
||||
flags=re.DOTALL
|
||||
)
|
||||
n_body = pattern.sub(lambda m: f"{m.group(1)}{open_md}{m.group(2)}{close_md}{m.group(3)}", n_body)
|
||||
return n_body
|
||||
|
||||
|
||||
def apply_service_tweaks(url, n_body, n_title, requested_output_format):
|
||||
|
||||
@@ -106,7 +156,7 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
|
||||
n_body = n_body.replace('<br>', '\n')
|
||||
n_body = n_body.replace('</br>', '\n')
|
||||
n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '\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>')
|
||||
@@ -140,15 +190,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 = 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, '**')
|
||||
n_body = apply_discord_markdown_to_body(n_body=n_body)
|
||||
|
||||
# Apply 2000 char limit for plain content
|
||||
payload_max_size = 1700
|
||||
@@ -180,6 +222,9 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
|
||||
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')
|
||||
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)
|
||||
|
||||
else: #plaintext etc default
|
||||
n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN, '(removed) ')
|
||||
@@ -300,17 +345,20 @@ def process_notification(n_object: NotificationContextData, datastore):
|
||||
apprise_input_format = NotifyFormat.TEXT.value
|
||||
|
||||
elif requested_output_format == NotifyFormat.MARKDOWN.value:
|
||||
# This actually means we request "Markdown to HTML", we want HTML output
|
||||
# Convert markdown to HTML ourselves since not all plugins do this
|
||||
from apprise.conversion import markdown_to_html
|
||||
# Make sure there are paragraph breaks around horizontal rules
|
||||
n_body = n_body.replace('---', '\n\n---\n\n')
|
||||
n_body = markdown_to_html(n_body)
|
||||
url = f"{url}{prefix_add_to_url}format={NotifyFormat.HTML.value}"
|
||||
requested_output_format = NotifyFormat.HTML.value
|
||||
apprise_input_format = NotifyFormat.MARKDOWN.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>\n')
|
||||
n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '<br>\r\n')
|
||||
else:
|
||||
# Markup, text types etc
|
||||
# texty types
|
||||
n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '\r\n')
|
||||
|
||||
sent_objs.append({'title': n_title,
|
||||
@@ -318,6 +366,12 @@ def process_notification(n_object: NotificationContextData, datastore):
|
||||
'url': url})
|
||||
apobj.add(url)
|
||||
|
||||
# Since the output is always based on the plaintext of the 'diff' engine, wrap it nicely.
|
||||
# It should always be similar to the 'history' part of the UI.
|
||||
if url.startswith('mail') and 'html' in requested_output_format:
|
||||
if not '<pre' in n_body and not '<body' in n_body: # No custom HTML-ish body was setup already
|
||||
n_body = as_monospaced_html_email(content=n_body, title=n_title)
|
||||
|
||||
apobj.notify(
|
||||
title=n_title,
|
||||
body=n_body,
|
||||
|
||||
@@ -290,7 +290,8 @@ def test_check_notification_markdown_format(client, live_server, measure_memory_
|
||||
text_part = parts[0]
|
||||
assert text_part.get_content_type() == 'text/plain'
|
||||
text_content = text_part.get_content()
|
||||
assert '(added) So let\'s see what happens.\r\n' in text_content # The plaintext part
|
||||
# We wont see anything in the "FALLBACK" text but that's OK (no added/strikethrough etc)
|
||||
assert 'So let\'s see what happens.\r\n' in text_content # The plaintext part
|
||||
|
||||
|
||||
# Second part should be text/html and roughly converted from markdown to HTML
|
||||
@@ -298,7 +299,7 @@ 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 '(added) So let\'s see what happens.<br' in html_content
|
||||
assert '<strong>So let\'s see what happens.</strong><br>' in html_content # Additions are <strong> in markdown
|
||||
delete_all_watches(client)
|
||||
|
||||
# Custom notification body with HTML, that is either sent as HTML or rendered to plaintext and sent
|
||||
@@ -470,7 +471,7 @@ def test_check_plaintext_document_plaintext_notification_smtp(client, live_serve
|
||||
assert '(added)' in body
|
||||
assert '<br' not in body
|
||||
assert '<' not in body
|
||||
|
||||
assert '<pre' not in body
|
||||
delete_all_watches(client)
|
||||
|
||||
def test_check_plaintext_document_html_notifications(client, live_server, measure_memory_usage):
|
||||
@@ -541,13 +542,13 @@ def test_check_plaintext_document_html_notifications(client, live_server, measur
|
||||
assert 'talk about <title>' in html_content
|
||||
# Should be the HTML, but not HTML Color
|
||||
assert 'background-color' not in html_content
|
||||
assert '<br>\r\n(added) And let's talk about <title> tags<br>' in html_content
|
||||
assert '<br>(added) And let's talk about <title> tags<br>' in html_content
|
||||
assert '<br' not in html_content
|
||||
assert '<pre role="article"' in html_content # Should have got wrapped nicely in email_helpers.py
|
||||
|
||||
# And now for the whitespace retention
|
||||
assert ' Some nice plain text' in html_content
|
||||
assert '(added) And let' in html_content # just to show a single whitespace didnt get touched
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
@@ -620,7 +621,7 @@ def test_check_plaintext_document_html_color_notifications(client, live_server,
|
||||
assert '(added) And let' not in html_content
|
||||
assert '<br' not in html_content
|
||||
assert '<br>' in html_content
|
||||
|
||||
assert '<pre role="article"' in html_content # Should have got wrapped nicely in email_helpers.py
|
||||
delete_all_watches(client)
|
||||
|
||||
def test_check_html_document_plaintext_notification(client, live_server, measure_memory_usage):
|
||||
|
||||
@@ -394,7 +394,8 @@ def test_api_import(client, live_server, measure_memory_usage):
|
||||
res = client.post(
|
||||
url_for("import") + "?tag=import-test",
|
||||
data='https://website1.com\r\nhttps://website2.com',
|
||||
headers={'x-api-key': api_key, 'content-type': 'text/plain'},
|
||||
# We removed 'content-type': 'text/plain', the Import API should assume this if none is set #3547 #3542
|
||||
headers={'x-api-key': api_key},
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ components:
|
||||
paused:
|
||||
type: boolean
|
||||
description: Whether the web page change monitor (watch) is paused
|
||||
muted:
|
||||
notification_muted:
|
||||
type: boolean
|
||||
description: Whether notifications are muted
|
||||
method:
|
||||
@@ -207,7 +207,7 @@ components:
|
||||
maxLength: 5000
|
||||
notification_format:
|
||||
type: string
|
||||
enum: [Text, HTML, Markdown]
|
||||
enum: ['Plain Text', 'HTML', 'HTML Color', 'Markdown to HTML', 'System default']
|
||||
description: Format for notifications
|
||||
track_ldjson_price_data:
|
||||
type: boolean
|
||||
@@ -406,7 +406,7 @@ paths:
|
||||
page_title: "The HTML <title> from the page"
|
||||
tags: ["550e8400-e29b-41d4-a716-446655440000"]
|
||||
paused: false
|
||||
muted: false
|
||||
notification_muted: false
|
||||
method: "GET"
|
||||
fetch_backend: "html_requests"
|
||||
last_checked: 1640995200
|
||||
@@ -419,7 +419,7 @@ paths:
|
||||
page_title: "The HTML <title> from the page"
|
||||
tags: ["330e8400-e29b-41d4-a716-446655440001"]
|
||||
paused: false
|
||||
muted: true
|
||||
notification_muted: true
|
||||
method: "GET"
|
||||
fetch_backend: "html_webdriver"
|
||||
last_checked: 1640998800
|
||||
@@ -1224,7 +1224,7 @@ paths:
|
||||
title: "Example Website Monitor"
|
||||
tags: ["550e8400-e29b-41d4-a716-446655440000"]
|
||||
paused: false
|
||||
muted: false
|
||||
notification_muted: false
|
||||
|
||||
/import:
|
||||
post:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# eventlet>=0.38.0 # Removed - replaced with threading mode for better Python 3.12+ compatibility
|
||||
feedgen~=0.9
|
||||
feedgen~=1.0
|
||||
feedparser~=6.0 # For parsing RSS/Atom feeds
|
||||
flask-compress
|
||||
# 0.6.3 included compatibility fix for werkzeug 3.x (2.x had deprecation of url handlers)
|
||||
@@ -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.13.0
|
||||
python-socketio~=5.14.2
|
||||
python-engineio~=4.12.3
|
||||
inscriptis~=2.2
|
||||
pytz
|
||||
@@ -22,7 +22,7 @@ validators~=0.35
|
||||
|
||||
# Set these versions together to avoid a RequestsDependencyWarning
|
||||
# >= 2.26 also adds Brotli support if brotli is installed
|
||||
brotli~=1.0
|
||||
brotli~=1.1
|
||||
requests[socks]
|
||||
requests-file
|
||||
|
||||
@@ -30,7 +30,7 @@ requests-file
|
||||
# If specific version needed for security, use urllib3>=1.26.19,<3.0
|
||||
chardet>2.3.0
|
||||
|
||||
wtforms~=3.0
|
||||
wtforms~=3.2
|
||||
jsonpath-ng~=1.5.3
|
||||
|
||||
# dnspython - Used by paho-mqtt for MQTT broker resolution
|
||||
|
||||
Reference in New Issue
Block a user