Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot]
abd520cb08 Bump cryptography from 44.0.0 to 46.0.7
Bumps [cryptography](https://github.com/pyca/cryptography) from 44.0.0 to 46.0.7.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/44.0.0...46.0.7)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.7
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 00:24:08 +00:00
dgtlmoon
ede06a92bd diff_changed_from/diff_changed_from tokens - improve documentation
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 App Test / test-application-3-14 (push) Has been cancelled
2026-04-09 08:32:13 +02:00
dgtlmoon
9d4249c820 Notification - Adding tokens {{diff_changed_from}} and {{diff_changed_to}} #3818 (#4031) 2026-04-09 08:28:08 +02:00
dgtlmoon
b5bac1c868 Fix SCREENSHOT_MAX_HEIGHT not enforced: cap viewport step_size and clip stitched output to max capture height #3810 (#4030) 2026-04-09 07:41:20 +02:00
dgtlmoon
0479aa9654 UI - Minor text fix and add link to 'Restock Backup' from Imports 2026-04-09 07:20:11 +02:00
11 changed files with 177 additions and 13 deletions

View File

@@ -20,8 +20,7 @@
<p>{{ _('Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).') }}</p>
<p>{{ _('Note: This does not override the main application settings, only watches and groups.') }}</p>
<p class="pure-form-message">
{{ _('Max upload size: %(upload)s MB &nbsp;·&nbsp; Max decompressed size: %(decomp)s MB',
upload=max_upload_mb, decomp=max_decompressed_mb) }}
{{ _('Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB', upload=max_upload_mb, decomp=max_decompressed_mb) }}
</p>
<form class="pure-form pure-form-stacked settings"

View File

@@ -9,6 +9,7 @@
<li class="tab" id=""><a href="#url-list">{{ _('URL List') }}</a></li>
<li class="tab"><a href="#distill-io">{{ _('Distill.io') }}</a></li>
<li class="tab"><a href="#xlsx">{{ _('.XLSX & Wachete') }}</a></li>
<li class="tab"><a href="{{url_for('backups.restore.restore')}}">{{ _('Backup Restore') }}</a></li>
</ul>
</div>

View File

@@ -49,6 +49,9 @@ async def capture_full_page_async(page, screenshot_format='JPEG', watch_uuid=Non
if page_height > page.viewport_size['height']:
if page_height < step_size:
step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size
# Never set viewport taller than our max capture height - otherwise one screenshot chunk
# captures the whole (e.g. 8098px) page even when SCREENSHOT_MAX_HEIGHT=1000
step_size = min(step_size, SCREENSHOT_MAX_TOTAL_HEIGHT)
viewport_start = time.time()
logger.debug(f"{watch_info}Setting bigger viewport to step through large page width W{page.viewport_size['width']}xH{step_size} because page_height > viewport_size")
# Set viewport to a larger size to capture more content at once

View File

@@ -75,6 +75,9 @@ async def capture_full_page(page, screenshot_format='JPEG', watch_uuid=None, loc
if page_height > page.viewport['height']:
if page_height < step_size:
step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size
# Never set viewport taller than our max capture height - otherwise one screenshot chunk
# captures the whole page even when SCREENSHOT_MAX_HEIGHT is set smaller
step_size = min(step_size, SCREENSHOT_MAX_TOTAL_HEIGHT)
viewport_start = time.time()
await page.setViewport({'width': page.viewport['width'], 'height': step_size})
viewport_time = time.time() - viewport_start

View File

@@ -56,6 +56,10 @@ def stitch_images_worker_raw_bytes(pipe_conn, original_page_height, capture_heig
im.close()
del images
# Clip stitched image to capture_height (chunks may overshoot by up to step_size-1 px)
if total_height > capture_height:
stitched = stitched.crop((0, 0, max_width, capture_height))
# Draw caption only if page was trimmed
if original_page_height > capture_height:
draw = ImageDraw.Draw(stitched)

View File

@@ -45,6 +45,36 @@ CHANGED_INTO_PLACEMARKER_CLOSED = '@changed_into_PLACEMARKER_CLOSED'
# Compiled regex patterns for performance
WHITESPACE_NORMALIZE_RE = re.compile(r'\s+')
# Regexes built from the constants above — no brittle hardcoded strings
_EXTRACT_REMOVED_RE = re.compile(
re.escape(REMOVED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(REMOVED_PLACEMARKER_CLOSED)
+ r'|' +
re.escape(CHANGED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_PLACEMARKER_CLOSED)
)
_EXTRACT_ADDED_RE = re.compile(
re.escape(ADDED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(ADDED_PLACEMARKER_CLOSED)
+ r'|' +
re.escape(CHANGED_INTO_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_INTO_PLACEMARKER_CLOSED)
)
def extract_changed_from(raw_diff: str) -> str:
"""Extract only the removed/changed-from fragments from a raw diff string.
Useful for {{diff_changed_from}} — gives just the old value (e.g. old price),
not the full surrounding line. Multiple fragments joined with newlines.
"""
return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_REMOVED_RE.finditer(raw_diff))
def extract_changed_to(raw_diff: str) -> str:
"""Extract only the added/changed-into fragments from a raw diff string.
Useful for {{diff_changed_to}} — gives just the new value (e.g. new price),
not the full surrounding line. Multiple fragments joined with newlines.
"""
return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_ADDED_RE.finditer(raw_diff))
def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html') -> tuple[str, bool]:
"""

View File

@@ -88,6 +88,28 @@ class FormattableTimestamp(str):
return self._dt.isoformat()
class FormattableExtract(str):
"""
A str subclass that holds only the extracted changed fragments from a diff.
Used for {{diff_changed_from}} and {{diff_changed_to}} tokens.
{{ diff_changed_from }} → old value(s) only, e.g. "$99.99"
{{ diff_changed_to }} → new value(s) only, e.g. "$109.99"
Multiple changed fragments are joined with newlines.
Being a str subclass means it is natively JSON serializable.
"""
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
if prev_snapshot or current_snapshot:
from changedetectionio import diff as diff_module
raw = diff_module.render_diff(prev_snapshot, current_snapshot, word_diff=True)
extracted = extract_fn(raw)
else:
extracted = ''
instance = super().__new__(cls, extracted)
return instance
class FormattableDiff(str):
"""
A str subclass representing a rendered diff. As a plain string it renders
@@ -161,6 +183,8 @@ class NotificationContextData(dict):
'diff_patch': FormattableDiff('', '', patch_format=True),
'diff_removed': FormattableDiff('', '', include_added=False),
'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=False),
'diff_changed_from': FormattableExtract('', '', extract_fn=lambda x: x),
'diff_changed_to': FormattableExtract('', '', extract_fn=lambda x: x),
'diff_url': None,
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
'notification_timestamp': time.time(),
@@ -244,16 +268,27 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},
}
from changedetectionio.diff import extract_changed_from, extract_changed_to
extract_specs = {
'diff_changed_from': extract_changed_from,
'diff_changed_to': extract_changed_to,
}
ret = {}
rendered_count = 0
# Only create FormattableDiff objects for diff keys actually used in the notification text
# Only create FormattableDiff/FormattableExtract objects for diff keys actually used in the notification text
for key in NotificationContextData().keys():
if key.startswith('diff') and key in diff_specs:
# Check if this placeholder is actually used in the notification text
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
if re.search(pattern, notification_scan_text, re.IGNORECASE):
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
rendered_count += 1
if not key.startswith('diff'):
continue
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
continue
if key in diff_specs:
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
rendered_count += 1
elif key in extract_specs:
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
rendered_count += 1
if rendered_count:
logger.trace(f"Rendered {rendered_count} diff placeholder(s) {sorted(ret.keys())} in {time.time() - now:.3f}s")

View File

@@ -98,6 +98,14 @@
<td><code>{{ '{{diff_patch}}' }}</code></td>
<td>{{ _('The diff output - patch in unified format') }}</td>
</tr>
<tr>
<td><code>{{ '{{diff_changed_from}}' }}</code></td>
<td>{{ _('Only the changed words/values from the previous version — e.g. the old price. Best when a single value changes per line; multiple changed fragments are joined by newline.') }}</td>
</tr>
<tr>
<td><code>{{ '{{diff_changed_to}}' }}</code></td>
<td>{{ _('Only the changed words/values from the new version — e.g. the new price. Best when a single value changes per line; multiple changed fragments are joined by newline.') }}</td>
</tr>
<tr>
<td><code>{{ '{{current_snapshot}}' }}</code></td>
<td>{{ _('The current snapshot text contents value, useful when combined with JSON or CSS filters') }}

View File

@@ -11,10 +11,10 @@ from changedetectionio.tests.util import set_original_response, set_modified_res
set_longer_modified_response, delete_all_watches
import logging
import os
# NOTE - RELIES ON mailserver as hostname running, see github build recipes
smtp_test_server = 'mailserver'
smtp_test_server = os.getenv('SMTP_TEST_MAILSERVER', 'mailserver')
ALL_MARKUP_TOKENS = ''.join(f"TOKEN: '{t}'\n{{{{{t}}}}}\n" for t in NotificationContextData().keys())

View File

@@ -15,7 +15,9 @@ from changedetectionio.diff import (
CHANGED_PLACEMARKER_OPEN,
CHANGED_PLACEMARKER_CLOSED,
CHANGED_INTO_PLACEMARKER_OPEN,
CHANGED_INTO_PLACEMARKER_CLOSED
CHANGED_INTO_PLACEMARKER_CLOSED,
extract_changed_from,
extract_changed_to,
)
@@ -381,5 +383,84 @@ Line 3 with tabs and spaces"""
self.assertNotIn('[-Line 2-]', output)
self.assertNotIn('[+Line 2+]', output)
def test_diff_changed_from_to_word_level(self):
"""Primary use case: extract just the old/new value from a changed line (e.g. price monitoring)"""
before = "Widget costs $99.99 per month"
after = "Widget costs $109.99 per month"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$99.99")
self.assertEqual(extract_changed_to(raw), "$109.99")
def test_diff_changed_from_to_multiple_changes(self):
"""Multiple changed fragments on different lines are joined with newline.
An unchanged line between the two changes ensures each is a 1-to-1 replace,
so word_diff fires per line rather than falling back to multi-line block mode."""
before = "Price $99\nunchanged\nTax $5"
after = "Price $149\nunchanged\nTax $12"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$99\n$5")
self.assertEqual(extract_changed_to(raw), "$149\n$12")
def test_diff_changed_from_to_pure_insert_delete(self):
"""Pure line additions/deletions (no inline word diff) are also captured"""
before = "old line"
after = "new line"
# word_diff=False forces line-level CHANGED markers
raw = diff.render_diff(before, after, word_diff=False)
self.assertEqual(extract_changed_from(raw), "old line")
self.assertEqual(extract_changed_to(raw), "new line")
def test_diff_changed_from_to_similar_numbers(self):
"""$90.00 → $9.00 must not produce a partial match like '0.00'.
The tokenizer splits on whitespace only, so '$90.00' and '$9.00' are
each a single atomic token — diff never sees their internal characters."""
before = "for sale $90.00"
after = "for sale $9.00"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$90.00")
self.assertEqual(extract_changed_to(raw), "$9.00")
def test_diff_changed_from_to_whole_line_replaced(self):
"""When every token on the line changed (no common tokens), render_inline_word_diff
takes the whole_line_replaced path using CHANGED/CHANGED_INTO markers instead of
REMOVED/ADDED. Extraction must still work via the alternation in the regex."""
before = "$99"
after = "$109"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$99")
self.assertEqual(extract_changed_to(raw), "$109")
def test_diff_changed_from_to_multiple_words_same_line(self):
"""When multiple words change on the same line all fragments are joined with newline.
'quick brown fox jumps' -> 'slow brown fox hops' gives 'quick\njumps' / 'slow\nhops'.
These tokens work best when a single value changes per line."""
before = "quick brown fox jumps"
after = "slow brown fox hops"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "quick\njumps")
self.assertEqual(extract_changed_to(raw), "slow\nhops")
def test_diff_changed_from_to_no_change(self):
"""No changes → empty string"""
content = "nothing changed here"
raw = diff.render_diff(content, content, word_diff=True)
self.assertEqual(extract_changed_from(raw), "")
self.assertEqual(extract_changed_to(raw), "")
if __name__ == '__main__':
unittest.main()

View File

@@ -51,7 +51,7 @@ linkify-it-py
# - Requires extra wheel for rPi, adds build time for arm/v8 which is not in piwheels
# Pinned to 44.x for ARM compatibility and sslyze compatibility (sslyze requires <45) and (45.x may not have pre-built ARM wheels)
# Also pinned because dependabot wants specific versions
cryptography==44.0.0
cryptography==46.0.7
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
# use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814