Compare commits

...

5 Commits

13 changed files with 436 additions and 153 deletions
+8 -5
View File
@@ -7,7 +7,7 @@ import threading
from flask import request
from . import auth
from . import validate_openapi_request
from . import validate_openapi_request, strip_internal_api_fields
class Tag(Resource):
@@ -85,7 +85,8 @@ class Tag(Resource):
# Create clean tag dict without Watch-specific fields
clean_tag = {k: v for k, v in tag.items() if k not in watch_only_fields}
return clean_tag
# Never expose `__`-prefixed transient/internal fields
return strip_internal_api_fields(clean_tag)
@auth.check_token
@validate_openapi_request('deleteTag')
@@ -113,8 +114,9 @@ class Tag(Resource):
if not tag:
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
# Make a mutable copy of request.json for modification
json_data = dict(request.json)
# Make a mutable copy of request.json for modification.
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
json_data = strip_internal_api_fields(dict(request.json))
# Validate notification_urls if provided
if 'notification_urls' in json_data:
@@ -162,7 +164,8 @@ class Tag(Resource):
def post(self):
"""Create a single tag/group."""
json_data = request.get_json()
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
json_data = strip_internal_api_fields(request.get_json())
title = json_data.get("title",'').strip()
# Validate that only valid fields are provided
+9 -5
View File
@@ -12,7 +12,7 @@ from flask_restful import abort, Resource
from loguru import logger
import copy
from . import validate_openapi_request, get_readonly_watch_fields
from . import validate_openapi_request, get_readonly_watch_fields, strip_internal_api_fields
from ..notification import valid_notification_formats
from ..notification.handler import newline_re
@@ -126,7 +126,8 @@ class Watch(Resource):
watch['processor_config_restock_diff'] = restock_config
watch['processor_config_restock_diff_source'] = restock_source
return watch
# Never expose `__`-prefixed transient/internal fields (e.g. __check_status)
return strip_internal_api_fields(watch)
@auth.check_token
@validate_openapi_request('deleteWatch')
@@ -187,8 +188,10 @@ class Watch(Resource):
# Handle processor-config-* fields separately (save to JSON, not datastore)
from changedetectionio import processors
# Make a mutable copy of request.json for modification
json_data = dict(request.json)
# Make a mutable copy of request.json for modification.
# Silently discard `__`-prefixed transient/internal keys — they are not part of the
# public schema and must never be writable (e.g. clients that round-trip GET → PUT).
json_data = strip_internal_api_fields(dict(request.json))
# Extract and remove processor config fields from json_data
processor_config_data = processors.extract_processor_config_from_form_data(json_data)
@@ -443,7 +446,8 @@ class CreateWatch(Resource):
def post(self):
"""Create a single watch."""
json_data = request.get_json()
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
json_data = strip_internal_api_fields(request.get_json())
url = json_data['url'].strip()
if not is_safe_valid_url(url):
+37
View File
@@ -133,6 +133,43 @@ def get_tag_schema_properties():
"""
return _resolve_schema_properties('Tag')
def strip_private_keys(data):
"""
Remove `__`-prefixed keys from a watch/tag dict at the API boundary.
These are transient in-memory fields (e.g. `__check_status` set by the worker to
surface "Fetching page..." in the UI) and are not part of the public OpenAPI
contract. They must never appear in GET responses (otherwise a client that
round-trips GET → PUT trips the unknown-field validator), and must be silently
discarded from incoming PUT/POST payloads.
Returns a new dict; the input is not mutated.
"""
if not isinstance(data, dict):
return data
return {k: v for k, v in data.items() if not (isinstance(k, str) and k.startswith('__'))}
def strip_internal_api_fields(data):
"""
Strip both `__`-prefixed keys AND system-managed fields that aren't in the public
OpenAPI spec (skip-cache hashes, LLM runtime state, processor-set status, etc.).
Use this at every public API boundary so GET responses and PUT/POST payloads agree
on what's part of the contract. The set of system-managed fields lives in
model/schema_utils.py:SYSTEM_MANAGED_NON_SPEC_FIELDS — extend it there, not here.
Returns a new dict; the input is not mutated.
"""
if not isinstance(data, dict):
return data
from changedetectionio.model.schema_utils import SYSTEM_MANAGED_NON_SPEC_FIELDS
return {
k: v for k, v in data.items()
if not (isinstance(k, str) and (k.startswith('__') or k in SYSTEM_MANAGED_NON_SPEC_FIELDS))
}
def validate_openapi_request(operation_id):
"""Decorator to validate incoming requests against OpenAPI spec."""
def decorator(f):
+7 -21
View File
@@ -343,28 +343,14 @@ class watch_base(dict):
return
# Import from shared schema utilities (no circular dependency)
from .schema_utils import get_readonly_watch_fields
readonly_fields = get_readonly_watch_fields()
from .schema_utils import get_readonly_watch_fields, SYSTEM_MANAGED_NON_SPEC_FIELDS
# Additional system-managed fields not in OpenAPI spec (yet)
# These are set by processors/workers and should not trigger edited flag
additional_system_fields = {
'last_check_status', # Set by processors
'last_filter_config_hash', # Set by text_json_diff processor, internal skip-cache
'restock', # Set by restock processor
'last_viewed', # Set by mark_all_viewed endpoint
# LLM runtime fields written back by worker/evaluator
'_llm_result',
'_llm_intent',
'_llm_change_summary',
'llm_prefilter',
'llm_evaluation_cache',
'llm_last_tokens_used',
'llm_tokens_used_cumulative',
}
# Only mark as edited if this is a user-writable field
if key not in readonly_fields and key not in additional_system_fields:
# `last_viewed` is set internally by mark_all_viewed and shouldn't flag the watch as
# edited, but is not in SYSTEM_MANAGED_NON_SPEC_FIELDS because it IS user-writable via
# the UpdateWatch schema (the API path).
if (key not in get_readonly_watch_fields()
and key != 'last_viewed'
and key not in SYSTEM_MANAGED_NON_SPEC_FIELDS):
self.__watch_was_edited = True
def __setitem__(self, key, value):
+29
View File
@@ -8,6 +8,35 @@ Shared by both the model layer and API layer to avoid circular dependencies.
import functools
# Watch fields written by workers/processors that are NOT part of the public OpenAPI spec.
#
# These fields exist on a watch dict at runtime but are internal implementation details
# (skip-cache hashes, last-check status strings, LLM runtime state, etc.). Used by:
# - model/__init__.py: don't trigger the "edited" flag when these are written internally
# - api/Watch.py: strip from GET responses and silently discard from PUT/POST inputs
# so that a GET → PUT round trip doesn't trip the unknown-field validator
#
# `last_viewed` is intentionally NOT included: it's set internally by mark_all_viewed BUT
# is also explicitly writable via the UpdateWatch schema (see api/Watch.py valid_fields).
SYSTEM_MANAGED_NON_SPEC_FIELDS = frozenset({
'last_check_status', # Set by processors
'last_filter_config_hash', # text_json_diff internal skip-cache
'restock', # Set by restock processor
'_llm_result', # LLM runtime — populated by evaluator
'_llm_intent',
'_llm_change_summary',
'llm_prefilter',
'llm_evaluation_cache',
'llm_last_tokens_used',
'llm_tokens_used_cumulative',
})
def get_system_managed_non_spec_fields():
"""Return the set of internal fields not in the public OpenAPI spec."""
return SYSTEM_MANAGED_NON_SPEC_FIELDS
@functools.cache
def get_openapi_schema_dict():
"""
@@ -495,16 +495,17 @@ class perform_site_check(difference_detection_processor):
# Start with content reference, avoid copy until modification
html_content = content
# Apply include filters (CSS, XPath, JSON)
# Except for plaintext (incase they tried to confuse the system, it will HTML escape
#if not stream_content_type.is_plaintext:
if filter_config.has_include_filters:
html_content = content_processor.apply_include_filters(content, stream_content_type)
# Apply subtractive selectors
# Apply subtractive selectors first so include filters operate on already-cleaned content.
# Otherwise a subtractive selector that relies on ancestor context (e.g. ".main .ads")
# cannot match after the include filter has extracted the inner element and stripped
# the parent wrapper.
if filter_config.has_subtractive_selectors:
html_content = content_processor.apply_subtractive_selectors(html_content)
# Apply include filters (CSS, XPath, JSON)
if filter_config.has_include_filters:
html_content = content_processor.apply_include_filters(html_content, stream_content_type)
# === TEXT EXTRACTION ===
if watch.is_source_type_url:
# For source URLs, keep raw content
@@ -550,30 +551,43 @@ class perform_site_check(difference_detection_processor):
update_obj["last_check_status"] = self.fetcher.get_last_status_code()
# Snapshot an ignore-applied stream BEFORE extract operations so line-level
# ignore patterns still match original content (#4138). Otherwise an extract_text
# regex like /(\d+\.\d+\.\d+)/ would transform "v.1.2.1" into "1.2.1" and the
# ignore_text pattern "v" would no longer match — meaning changes to ignored
# lines would incorrectly affect the checksum.
text_for_checksuming = None
if filter_config.ignore_text:
text_for_checksuming = html_tools.strip_ignore_text(stripped_text, filter_config.ignore_text)
# === LINE FILTER (plain-text substring) ===
if filter_config.extract_lines_containing:
stripped_text = transformer.extract_lines_containing(stripped_text, filter_config.extract_lines_containing)
if text_for_checksuming is not None:
text_for_checksuming = transformer.extract_lines_containing(text_for_checksuming, filter_config.extract_lines_containing)
# === REGEX EXTRACTION ===
if filter_config.extract_text:
extracted = transformer.extract_by_regex(stripped_text, filter_config.extract_text)
stripped_text = extracted
stripped_text = transformer.extract_by_regex(stripped_text, filter_config.extract_text)
if text_for_checksuming is not None:
text_for_checksuming = transformer.extract_by_regex(text_for_checksuming, filter_config.extract_text)
# === MORE TEXT TRANSFORMATIONS ===
if watch.get('remove_duplicate_lines'):
stripped_text = transformer.remove_duplicate_lines(stripped_text)
if text_for_checksuming is not None:
text_for_checksuming = transformer.remove_duplicate_lines(text_for_checksuming)
if watch.get('sort_text_alphabetically'):
stripped_text = transformer.sort_alphabetically(stripped_text)
if text_for_checksuming is not None:
text_for_checksuming = transformer.sort_alphabetically(text_for_checksuming)
# === CHECKSUM CALCULATION ===
text_for_checksuming = stripped_text
# Apply ignore_text for checksum calculation
if filter_config.ignore_text:
text_for_checksuming = html_tools.strip_ignore_text(stripped_text, filter_config.ignore_text)
# Optionally remove ignored lines from output
if text_for_checksuming is None:
text_for_checksuming = stripped_text
else:
# Optionally remove ignored lines from displayed output too
strip_ignored_lines = watch.get('strip_ignored_lines')
if strip_ignored_lines is None:
strip_ignored_lines = self.datastore.data['settings']['application'].get('strip_ignored_lines')
+100
View File
@@ -406,6 +406,106 @@ def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path
"extract_lines_containing should be persisted and returned via API"
def test_api_strips_internal_fields(client, live_server, measure_memory_usage, datastore_path):
"""
Internal/transient fields must never cross the API boundary in either direction:
1. `__`-prefixed keys (e.g. `__check_status` set by the worker for UI status)
2. System-managed fields not in the OpenAPI spec (see SYSTEM_MANAGED_NON_SPEC_FIELDS):
`last_check_status`, `last_filter_config_hash`, `_llm_*`, `llm_*`, etc.
GET responses must strip them. PUT/POST payloads must silently discard them.
Without this, a client that round-trips GET PUT trips the unknown-field validator.
"""
from changedetectionio.model.schema_utils import SYSTEM_MANAGED_NON_SPEC_FIELDS
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
datastore = live_server.app.config['DATASTORE']
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Create
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True
)
assert res.status_code == 201
watch_uuid = res.json.get('uuid')
wait_for_all_checks(client)
# Force both a transient __-prefixed and a system-managed field onto the watch,
# simulating worker/processor-set state.
watch_obj = datastore.data['watching'][watch_uuid]
watch_obj['__check_status'] = 'Fetching page..'
watch_obj['last_check_status'] = 200
watch_obj['_llm_result'] = {'summary': 'cached llm output'}
watch_obj['last_filter_config_hash'] = 'abc123'
# --- GET must strip all internal fields ---
res = client.get(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key},
)
assert res.status_code == 200
assert not any(k.startswith('__') for k in res.json.keys()), \
f"No __-prefixed field should leak into API responses; got keys: {list(res.json.keys())}"
leaked_system_fields = SYSTEM_MANAGED_NON_SPEC_FIELDS & set(res.json.keys())
assert not leaked_system_fields, \
f"System-managed non-spec fields must not appear in GET response; leaked: {leaked_system_fields}"
# --- PUT must accept (and silently drop) those same internal fields ---
# This is the key round-trip property: a client should be able to PUT back what it just GET'd.
# Use the actual GET response as the payload (the realistic round-trip case).
payload = dict(res.json)
payload['__check_status'] = 'attacker-supplied value' # not in the GET, but a client could add it
payload['last_check_status'] = 999 # ditto
payload['_llm_result'] = 'attacker overwrite'
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps(payload),
)
assert res.status_code == 200, \
f"PUT round-tripping GET response plus internal fields should succeed (got {res.status_code}: {res.data!r})"
# Internal fields must not have been overwritten by the PUT
assert watch_obj.get('__check_status') == 'Fetching page..', \
"PUT must not overwrite __-prefixed fields"
assert watch_obj.get('_llm_result') == {'summary': 'cached llm output'}, \
"PUT must not overwrite system-managed non-spec fields"
# --- POST must also silently discard internal fields ---
# Use unique sentinel values so we can distinguish "POST persisted my value" from
# "the worker concurrently re-set the field while processing the new watch".
attacker_check_status = 'attacker-sentinel-__check_status-9f7c'
attacker_llm_result = 'attacker-sentinel-_llm_result-9f7c'
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url + "?2",
"__check_status": attacker_check_status,
"_llm_result": attacker_llm_result,
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True,
)
assert res.status_code == 201, \
f"POST with internal fields should succeed (got {res.status_code}: {res.data!r})"
new_uuid = res.json.get('uuid')
new_watch = datastore.data['watching'][new_uuid]
# If POST had persisted the attacker payload these specific sentinel values would remain.
# The worker may legitimately re-set __check_status with its own status string, that's fine.
assert new_watch.get('__check_status') != attacker_check_status, \
"POST must not persist __-prefixed fields from input"
assert new_watch.get('_llm_result') != attacker_llm_result, \
"POST must not persist system-managed fields from input"
delete_all_watches(client)
def test_access_denied(client, live_server, measure_memory_usage, datastore_path):
# `config_api_token_enabled` Should be On by default
res = client.get(
@@ -251,3 +251,41 @@ body > table > tr:nth-child(3) > td:nth-child(3)""",
# First column should exist
assert b"Emil" in res.data
# Re PR #978: subtractive_selectors must run BEFORE include_filters so that selectors
# relying on ancestor context (e.g. ".main .ad") can still match. If include runs first,
# the ancestor wrapper is stripped and the subtractive selector matches nothing.
def test_subtractive_selectors_applied_before_include_filters(client, live_server, measure_memory_usage, datastore_path):
page_html = """<html><body>
<div class="main">
<p class="keep">first kept paragraph</p>
<p class="advertisement">noisy advertisement text</p>
<p class="keep">second kept paragraph</p>
</div>
</body></html>
"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(page_html)
test_url = url_for("test_endpoint", _external=True)
client.application.config.get('DATASTORE').add_watch(
url=test_url,
extras={
# Include filter strips the .main wrapper from the output
"include_filters": [".main p"],
# Subtractive selector depends on the .main ancestor — only effective if it runs first
"subtractive_selectors": [".main .advertisement"],
},
)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True,
)
assert b"first kept paragraph" in res.data
assert b"second kept paragraph" in res.data
# The bug: ad survives if include filter runs first
assert b"noisy advertisement text" not in res.data
@@ -559,3 +559,78 @@ def test_extract_lines_containing_with_include_filters_css(client, live_server,
assert b'forecast' not in res.data
delete_all_watches(client)
# Re issue #4138: ignore_text must take effect BEFORE extract_text regex, otherwise the
# regex transforms line content (e.g. "v.1.2.1" -> "1.2.1") and ignore_text patterns
# like "v"/"rc" can no longer match — causing changes to ignored lines to incorrectly
# trigger change-detection.
def test_ignore_text_applied_before_extract_text_regex(client, live_server, measure_memory_usage, datastore_path):
initial_data = """<html><body>
<p>0.8.9</p>
<p>v.1.2.1</p>
<p>rc-1.0.0</p>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(initial_data)
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'paused': True})
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1),
data={
'ignore_text': 'v\r\nrc',
'extract_text': r'/(\d+\.\d+\.\d+)/',
"url": test_url,
"tags": "",
"headers": "",
'fetch_backend': "html_requests",
"time_between_check_use_default": "y",
},
follow_redirects=True
)
assert b"unpaused" in res.data
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# Bump only the IGNORED lines — these should not move the checksum
changed_data = """<html><body>
<p>0.8.9</p>
<p>v.1.3.0</p>
<p>rc-2.0.0</p>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(changed_data)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))
assert b'has-unread-changes' not in res.data, \
"Changing only ignored lines should not trigger a change even when extract_text regex is set"
client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
time.sleep(1)
# Now bump the non-ignored line — this SHOULD trigger
triggered_data = """<html><body>
<p>0.9.0</p>
<p>v.1.3.0</p>
<p>rc-2.0.0</p>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(triggered_data)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))
assert b'has-unread-changes' in res.data, \
"Changing a non-ignored line should still trigger a change"
delete_all_watches(client)
@@ -77,7 +77,7 @@ msgstr "Soubor musí být .zip soubor zálohy!"
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
msgstr "Záložní soubor moc velký (max %(mb)s MB)"
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
@@ -136,7 +136,7 @@ msgstr "Pozn.: Nepřepíše hlavní nastavení aplikaci, pouze sledování a sku
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
msgstr "Max. velikost nahrání: %(upload)s MB, Max. velikost k rozbalení: %(decomp)s MB"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
@@ -210,7 +210,7 @@ msgstr ".XLSX a Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
msgstr "Obnova zálohy"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
@@ -362,12 +362,12 @@ msgstr "Všechna oznámení odtlumena."
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
msgstr "AI / LLM konfigurace odstraněna."
#: changedetectionio/blueprint/settings/llm.py
#, python-brace-format
msgid "AI summary cache cleared ({} file(s) removed)."
msgstr ""
msgstr "AI cache souhrnů vyčištěna ({}s soubor(ů) odstraněno)."
#: changedetectionio/blueprint/settings/templates/notification-log.html
msgid "Notification debug log"
@@ -405,7 +405,7 @@ msgstr "CAPTCHA a proxy"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "AI / LLM"
msgstr ""
msgstr "AI / LLM"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Info"
@@ -433,15 +433,15 @@ msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
msgstr "Nastavit prázdnou hodnotu pro vypnutí / bez limitu"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
msgstr "Chránit heslem tuto changedetection.io applikaci"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password is locked."
msgstr ""
msgstr "Heslo je uzamčeno."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Allow access to the watch change history page when password is enabled (Good for sharing the diff page)"
@@ -449,7 +449,7 @@ msgstr "Povolit přístup na stránku historie změn monitoru, když je povoleno
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
msgstr "Pokud požadavek vrátí prázdný obsah, nebo pokud HTML neobsahuje žádný text, má být označeno jako změna?"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
@@ -457,7 +457,7 @@ msgstr "Vyberte výchozí proxy pro všechna sledování"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
msgstr "Základní URL použita pro"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "token in notification links."
@@ -465,7 +465,7 @@ msgstr "token v odkazech oznámení."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Default value is the system environment variable"
msgstr ""
msgstr "Výchozí hodnota je systémová proměnná prostředí"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html
msgid "read more here"
@@ -485,7 +485,7 @@ msgstr ""
msgid ""
"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time"
" here."
msgstr ""
msgstr "Pokud máte potíže při čekání na plné vykreslení stránky (chybějící text atp.), zkuste navýšit čas 'prodlevy' zde."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "This will wait <i>n</i> seconds before extracting the text."
@@ -493,7 +493,7 @@ msgstr "Toto počká <i>n</i> sekund před extrahováním textu."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Number of concurrent workers to process watches. More workers = faster processing but higher memory usage."
msgstr ""
msgstr "Počet souběžných pracovních procesů sledování. Více procesů = rychlejší zpracování, ale vyšší spotřeba paměti."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Currently running:"
@@ -513,27 +513,27 @@ msgstr "aktivně zpracovává"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later"
msgstr ""
msgstr "Příklad - 3 sekundový náhodný rozptyl může spustit o 3 sekundy dříve nebo až 3 sekundy později"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999."
msgstr ""
msgstr "Pro běžné základní požadavky (bez použití chrome), maximální počet sekund do vypršení, 1-999."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Applied to all requests."
msgstr ""
msgstr "Nastaveno pro všechny požadavky."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider"
msgstr ""
msgstr "Pozn.: Pouhá změna hodnoty User-Agent často neobejde technologie zamezující přístup robotů, je třeba vzít v potaz"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "all of the ways that the browser is detected"
msgstr ""
msgstr "všechny možnosti jak lze prohlížeč rozpoznat."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
msgstr "Připojit pomocí Bright Data proxy, více se lze dozvědět zde."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
@@ -542,7 +542,7 @@ msgstr "Tip:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr ""
msgstr "Ignorovat mezery, tabulátory a nové řádky/odřádkování, při odhadu zda došlo ke změně."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Note:"
@@ -550,31 +550,31 @@ msgstr "Poznámka:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Changing this will change the status of your existing watches, possibly trigger alerts etc."
msgstr ""
msgstr "Při změně této hodnoty se změní stav existujících sledování a to pravděpodobně spustí upozornění atp."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Render anchor tag content, default disabled, when enabled renders links as"
msgstr ""
msgstr "Vykreslit obsah kotvícího tagu, výchozí vypnuto, při zapnutí vykresluje odkazu jako"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
msgstr "Při změně této hodnoty se nejspíše změní stav existujících sledování a to nejspíše spustí upozornění atp."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
msgstr "Odstranit HTML element(y) pomocí CSS a XPath značek před konverzí textu."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
msgstr "Nevkládat HTML, ale pouze CSS a XPath značky"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
msgstr "Přidat vícero elementů, CSS nebo XPath značky vždy na novou řádku, aby bylo postupně ignorováno více částí HTML."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Note: This is applied globally in addition to the per-watch rules."
msgstr ""
msgstr "Pozn.: Toto je aplikováno globálně dodatečně k pravidlům nastaveným pro jednotlivá sledování."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
@@ -582,47 +582,47 @@ msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
msgstr ""
msgstr "Každá řádka zpracována samostatně, odpovídající řádky budou ignorovány (odstraněny před založením kontrolního součtu)"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
msgid "Regular Expression support, wrap the entire line in forward slash"
msgstr ""
msgstr "Podpora regulárních výrazů, ohraničit celé řádky lomítkem"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
msgid "Changing this will affect the comparison checksum which may trigger an alert"
msgstr ""
msgstr "Změna této hodnoty ovlivní porovnávací kontrolní součet, což může spustit upozornění"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove any text that appears in the \"Ignore text\" from the output (otherwise its just ignored for change-detection)"
msgstr ""
msgstr "Odstranit všechen text z výstupu zadaný pod \"Ignorovat text\" (jinak bude ignorováno pouze pro detekci změn)"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "API Access"
msgstr ""
msgstr "API Přístup"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Drive your changedetection.io via API, More about"
msgstr ""
msgstr "Ovládejte svou changedetection.io pomocí API, Více o"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "API access and examples here"
msgstr "Přístup k API a příklady zde"
msgstr "přístupu k API a příklady zde"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Restrict API access limit by using"
msgstr ""
msgstr "Omezit API přístupový limit použitím"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "header - required for the Chrome Extension to work"
msgstr ""
msgstr "hlavičky - vyžadováno pro správné fungování Chrome rozšíření"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "copy"
msgstr ""
msgstr "kopírovat"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Regenerate API key"
msgstr ""
msgstr "Obnovit API klíč"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Chrome Extension"
@@ -630,43 +630,43 @@ msgstr "Rozšíření pro Chrome"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Easily add any web-page to your changedetection.io installation from within Chrome."
msgstr ""
msgstr "Přidávejte jakékoliv webové stránky do své changedetection.io instalace přímo z prohlížeče Chrome."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Step 1"
msgstr ""
msgstr "Krok 1"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Install the extension,"
msgstr ""
msgstr "Nainstalovat rozšíření,"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Step 2"
msgstr ""
msgstr "Krok 2"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Navigate to this page,"
msgstr ""
msgstr "Navigovat na tuto stránku,"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Step 3"
msgstr ""
msgstr "Krok 3"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Open the extension from the toolbar and click"
msgstr ""
msgstr "Otevřít rozšíření z lišty a kliknout"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Sync API Access"
msgstr ""
msgstr "Synchronizovat API přístup"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Try our new Chrome Extension!"
msgstr ""
msgstr "Ozkoušet naše nové Chrom rozšíření"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Chrome store icon"
msgstr ""
msgstr "ikona obchodu Chrome"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Chrome Webstore"
@@ -674,15 +674,15 @@ msgstr "Chrome Webstore"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of history snapshots to include in the watch specific RSS feed."
msgstr ""
msgstr "Maximální počet snímků historie přiřazených ke sledování specifického RSS zdroje."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection."
msgstr ""
msgstr "Sledování dalších RSS zdrojů - Při sledování RSS/Atom zdrojů, převádět na obyčejný text pro lepší sledování změn."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Does your reader support HTML? Set it here"
msgstr ""
msgstr "Máte čtečku podporující HTML? Nastavit zde"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "'System default' for the same template for all items, or re-use your \"Notification Body\" as the template."
@@ -690,23 +690,23 @@ msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches."
msgstr ""
msgstr "Ujistěte se, že nastavení níže je správně, je použito pro časové rozestupy kontrol sledování webových stránek."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "UTC Time & Date from Server:"
msgstr ""
msgstr "UTC Čas a Datum Serveru:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Local Time & Date in Browser:"
msgstr ""
msgstr "Místní Čas a Datum prohlížeče:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab."
msgstr ""
msgstr "Po povolení tohoto nastavení bude stránka rozdílů otevřena v novém tabu. Při vypnutí bude použit aktuální tab."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Realtime UI Updates Enabled - (Restart required if this is changed)"
msgstr ""
msgstr "Povolit aktualizace UI v reálném čase - (změna vyžaduje restart)"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Enable or Disable Favicons next to the watch list"
@@ -2345,31 +2345,31 @@ msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
msgstr "Větší než"
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
msgstr "Menší než"
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
msgstr "Větší než nebo shodný s"
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
msgstr "Menší než nebo shodný s"
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
msgstr "Shoduje se s"
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
msgstr "Neshoduje se"
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
msgstr "Obsahuje"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
@@ -2811,12 +2811,12 @@ msgstr "Použít globální nastavení pro čas mezi kontrolou a plánovačem."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgstr ""
msgstr "AI záměr změny"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
msgid "AI Change Summary"
msgstr ""
msgstr "AI souhrn změny"
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
@@ -2828,7 +2828,7 @@ msgstr "Odstranit prvky"
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
msgstr "Extrahovat řádky obsahující"
#: changedetectionio/forms.py
msgid "Extract text"
@@ -3055,7 +3055,7 @@ msgstr "Základní URL pro upozornění"
#: changedetectionio/forms.py
msgid "Not set"
msgstr ""
msgstr "Nenastaveno"
#: changedetectionio/forms.py
msgid "Treat empty pages as a change?"
@@ -3063,7 +3063,7 @@ msgstr "Považovat prázdné stránky za změnu?"
#: changedetectionio/forms.py
msgid "Ignore Text"
msgstr "Text chyby"
msgstr "Ignorovat text"
#: changedetectionio/forms.py
msgid "Ignore whitespace"
@@ -3071,7 +3071,7 @@ msgstr "Ignorujte mezery"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
msgstr "Screenshot: minimální procento změny"
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
@@ -3135,7 +3135,7 @@ msgstr "Kolikrát může filtr chybět před odesláním upozornění"
#: changedetectionio/forms.py
msgid "Model"
msgstr ""
msgstr "Model"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/forms.py
msgid "API Key"
@@ -3163,7 +3163,7 @@ msgstr ""
#: changedetectionio/forms.py
msgid "Monthly token budget"
msgstr ""
msgstr "Měsíční rozpočet tokenů"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max input characters"
@@ -3179,7 +3179,7 @@ msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
msgstr "AI pracovní rozpočet (tokeny)"
#: changedetectionio/forms.py
msgid "Off (no thinking)"
@@ -3191,7 +3191,7 @@ msgstr ""
#: changedetectionio/forms.py
msgid "When monthly token budget is reached"
msgstr ""
msgstr "Při dosažení měsíčního rozpočtu tokenů"
#: changedetectionio/forms.py
msgid "Skip AI summarisation only (watch still checks)"
@@ -3277,7 +3277,7 @@ msgstr "Porovnání snímků obrazovky"
#: changedetectionio/processors/image_ssim_diff/preview.py
msgid "Preview unavailable - No snapshots captured yet"
msgstr ""
msgstr "Náhled nedostupný - Zatím nebyly pořízeny žádné snapshoty"
#: changedetectionio/processors/image_ssim_diff/processor.py
msgid "Visual / Image screenshot change detection"
@@ -3779,7 +3779,7 @@ msgstr ""
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
msgstr "Je dostupná nová verze"
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
@@ -3787,7 +3787,7 @@ msgstr "Vyhledejte nebo použijte klávesu Alt+S"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
msgstr "Sdílet tento odkaz:"
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
@@ -3840,7 +3840,7 @@ msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
msgstr "AI"
#: changedetectionio/templates/edit/include_llm_intent.html
msgid ""
@@ -4104,23 +4104,23 @@ msgstr "IMPORTOVAT"
#: changedetectionio/templates/menu.html
msgid "Resume automatic scheduling"
msgstr ""
msgstr "Pokračovat s automatickým naplánováním"
#: changedetectionio/templates/menu.html
msgid "Pause auto-queue scheduling of watches"
msgstr ""
msgstr "Pozastavit automatické řazení plánovaných sledovaní"
#: changedetectionio/templates/menu.html
msgid "Scheduling is paused - click to resume"
msgstr ""
msgstr "Naplánování je pozastaveno - klikněte pro opětovné spuštění"
#: changedetectionio/templates/menu.html
msgid "Unmute notifications"
msgstr "Odtlumit oznámení"
msgstr "Opět povolit oznámení"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "Oznámení jsou ztlumena - klikněte pro odtlumení"
msgstr "Oznámení jsou ztlumena - klikněte pro opětovné povolení"
#: changedetectionio/templates/menu.html
msgid "EDIT"
@@ -4136,11 +4136,11 @@ msgstr ""
#: changedetectionio/templates/menu.html
msgid "Toggle AI Mode"
msgstr ""
msgstr "Přepnout AI Mód"
#: changedetectionio/templates/menu.html
msgid "Toggle AI mode"
msgstr ""
msgstr "Přepnout AI mód"
#: changedetectionio/templates/menu.html
msgid "Toggle Light/Dark Mode"
+1 -1
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.55.3\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-05-12 17:39+0200\n"
"POT-Creation-Date: 2026-05-15 12:51+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
+17 -20
View File
@@ -1,27 +1,24 @@
#!/bin/bash
set -e
# Install additional packages from EXTRA_PACKAGES env var
# Uses a marker file to avoid reinstalling on every container restart
INSTALLED_MARKER="/datastore/.extra_packages_installed"
CURRENT_PACKAGES="$EXTRA_PACKAGES"
# Install additional Python packages from the EXTRA_PACKAGES env var.
#
# Why no marker / skip-cache:
# A previous version of this script wrote a marker file to
# /datastore/.extra_packages_installed and skipped pip when it was present.
# That marker lived on the persistent /datastore volume, but the pip-installed
# packages live in the container's writable layer — so after a `docker compose
# down && up` (or any container recreation) the packages were gone while the
# marker remained, and the script wrongly believed everything was installed.
# See: https://github.com/dgtlmoon/changedetection.io/issues/4140
#
# Running pip on every start is correct by construction: when the requirements
# are already satisfied, pip is a fast no-op ("Requirement already satisfied"),
# adding ~1s per package. That's a small price for not lying about the install
# state — and pip's own resolver is the authoritative check, not a flat file.
if [ -n "$EXTRA_PACKAGES" ]; then
# Check if we need to install/update packages
if [ ! -f "$INSTALLED_MARKER" ] || [ "$(cat $INSTALLED_MARKER 2>/dev/null)" != "$CURRENT_PACKAGES" ]; then
echo "Installing extra packages: $EXTRA_PACKAGES"
pip3 install --no-cache-dir $EXTRA_PACKAGES
if [ $? -eq 0 ]; then
echo "$CURRENT_PACKAGES" > "$INSTALLED_MARKER"
echo "Extra packages installed successfully"
else
echo "ERROR: Failed to install extra packages"
exit 1
fi
else
echo "Extra packages already installed: $EXTRA_PACKAGES"
fi
echo "Ensuring extra packages installed: $EXTRA_PACKAGES"
pip3 install --no-cache-dir $EXTRA_PACKAGES
fi
# Execute the main command