mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-02-05 22:06:07 +00:00
Compare commits
16 Commits
puppeteer-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1058debc12 | ||
|
|
61b41b0b16 | ||
|
|
efe3afd383 | ||
|
|
84d26640cc | ||
|
|
2349344d9e | ||
|
|
bdc2916c07 | ||
|
|
4fd477a60c | ||
|
|
dc8b387f40 | ||
|
|
2149a6fe3b | ||
|
|
f77d2bac6d | ||
|
|
75ecd1b793 | ||
|
|
4fe2a67839 | ||
|
|
5bbbe37436 | ||
|
|
83d7ce0fcf | ||
|
|
6bea9909ec | ||
|
|
1aabf967ef |
@@ -395,6 +395,29 @@ jobs:
|
||||
cd changedetectionio
|
||||
./run_custom_browser_url_tests.sh
|
||||
|
||||
processor-plugin-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||
path: /tmp
|
||||
|
||||
- name: Load Docker image
|
||||
run: |
|
||||
docker load -i /tmp/test-changedetectionio.tar
|
||||
|
||||
- name: Basic processor plugin registration and checks
|
||||
run: |
|
||||
docker run -e EXTRA_PACKAGES=changedetection.io-osint-processor test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_processor.py::test_check_plugin_processor'
|
||||
|
||||
# Container startup tests
|
||||
container-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -138,6 +138,15 @@ ENV LOGGER_LEVEL="$LOGGER_LEVEL"
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy and set up entrypoint script for installing extra packages
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Set entrypoint to handle EXTRA_PACKAGES env var
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
# Default command (can be overridden in docker-compose.yml)
|
||||
CMD ["python", "./changedetection.py", "-d", "/datastore"]
|
||||
|
||||
|
||||
|
||||
@@ -66,47 +66,42 @@ class Watch(Resource):
|
||||
@validate_openapi_request('getWatch')
|
||||
def get(self, uuid):
|
||||
"""Get information about a single watch, recheck, pause, or mute."""
|
||||
import time
|
||||
from copy import deepcopy
|
||||
watch = None
|
||||
# Retry up to 20 times if dict is being modified
|
||||
# With sleep(0), this is fast: ~200µs best case, ~20ms worst case under heavy load
|
||||
for attempt in range(20):
|
||||
try:
|
||||
watch = deepcopy(self.datastore.data['watching'].get(uuid))
|
||||
break
|
||||
except RuntimeError:
|
||||
# Dict changed during deepcopy, retry after yielding to scheduler
|
||||
# sleep(0) releases GIL and yields - no fixed delay, just lets other threads run
|
||||
if attempt < 19: # Don't yield on last attempt
|
||||
time.sleep(0) # Yield to scheduler (microseconds, not milliseconds)
|
||||
|
||||
if not watch:
|
||||
# Get watch reference first (for pause/mute operations)
|
||||
watch_obj = self.datastore.data['watching'].get(uuid)
|
||||
if not watch_obj:
|
||||
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
||||
|
||||
# Create a dict copy for JSON response (with lock for thread safety)
|
||||
# This is much faster than deepcopy and doesn't copy the datastore reference
|
||||
# WARNING: dict() is a SHALLOW copy - nested dicts are shared with original!
|
||||
# Only safe because we only ADD scalar properties (line 97-101), never modify nested dicts
|
||||
# If you need to modify nested dicts, use: from copy import deepcopy; watch = deepcopy(dict(watch_obj))
|
||||
with self.datastore.lock:
|
||||
watch = dict(watch_obj)
|
||||
|
||||
if request.args.get('recheck'):
|
||||
worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
return "OK", 200
|
||||
if request.args.get('paused', '') == 'paused':
|
||||
self.datastore.data['watching'].get(uuid).pause()
|
||||
watch_obj.pause()
|
||||
return "OK", 200
|
||||
elif request.args.get('paused', '') == 'unpaused':
|
||||
self.datastore.data['watching'].get(uuid).unpause()
|
||||
watch_obj.unpause()
|
||||
return "OK", 200
|
||||
if request.args.get('muted', '') == 'muted':
|
||||
self.datastore.data['watching'].get(uuid).mute()
|
||||
watch_obj.mute()
|
||||
return "OK", 200
|
||||
elif request.args.get('muted', '') == 'unmuted':
|
||||
self.datastore.data['watching'].get(uuid).unmute()
|
||||
watch_obj.unmute()
|
||||
return "OK", 200
|
||||
|
||||
# Return without history, get that via another API call
|
||||
# Properties are not returned as a JSON, so add the required props manually
|
||||
watch['history_n'] = watch.history_n
|
||||
watch['history_n'] = watch_obj.history_n
|
||||
# attr .last_changed will check for the last written text snapshot on change
|
||||
watch['last_changed'] = watch.last_changed
|
||||
watch['viewed'] = watch.viewed
|
||||
watch['link'] = watch.link,
|
||||
watch['last_changed'] = watch_obj.last_changed
|
||||
watch['viewed'] = watch_obj.viewed
|
||||
watch['link'] = watch_obj.link,
|
||||
|
||||
return watch
|
||||
|
||||
@@ -169,58 +164,18 @@ class Watch(Resource):
|
||||
|
||||
# Handle processor-config-* fields separately (save to JSON, not datastore)
|
||||
from changedetectionio import processors
|
||||
processor_config_data = {}
|
||||
regular_data = {}
|
||||
|
||||
for key, value in request.json.items():
|
||||
if key.startswith('processor_config_'):
|
||||
config_key = key.replace('processor_config_', '')
|
||||
if value: # Only save non-empty values
|
||||
processor_config_data[config_key] = value
|
||||
else:
|
||||
regular_data[key] = value
|
||||
# Make a mutable copy of request.json for modification
|
||||
json_data = dict(request.json)
|
||||
|
||||
# Extract and remove processor config fields from json_data
|
||||
processor_config_data = processors.extract_processor_config_from_form_data(json_data)
|
||||
|
||||
# Update watch with regular (non-processor-config) fields
|
||||
watch.update(regular_data)
|
||||
watch.update(json_data)
|
||||
|
||||
# Save processor config to JSON file if any config data exists
|
||||
if processor_config_data:
|
||||
try:
|
||||
processor_name = request.json.get('processor', watch.get('processor'))
|
||||
if processor_name:
|
||||
# Create a processor instance to access config methods
|
||||
from changedetectionio.processors import difference_detection_processor
|
||||
processor_instance = difference_detection_processor(self.datastore, uuid)
|
||||
# Use processor name as filename so each processor keeps its own config
|
||||
config_filename = f'{processor_name}.json'
|
||||
processor_instance.update_extra_watch_config(config_filename, processor_config_data)
|
||||
logger.debug(f"API: Saved processor config to {config_filename}: {processor_config_data}")
|
||||
|
||||
# Call optional edit_hook if processor has one
|
||||
try:
|
||||
import importlib
|
||||
edit_hook_module_name = f'changedetectionio.processors.{processor_name}.edit_hook'
|
||||
|
||||
try:
|
||||
edit_hook = importlib.import_module(edit_hook_module_name)
|
||||
logger.debug(f"API: Found edit_hook module for {processor_name}")
|
||||
|
||||
if hasattr(edit_hook, 'on_config_save'):
|
||||
logger.info(f"API: Calling edit_hook.on_config_save for {processor_name}")
|
||||
# Call hook and get updated config
|
||||
updated_config = edit_hook.on_config_save(watch, processor_config_data, self.datastore)
|
||||
# Save updated config back to file
|
||||
processor_instance.update_extra_watch_config(config_filename, updated_config)
|
||||
logger.info(f"API: Edit hook updated config: {updated_config}")
|
||||
else:
|
||||
logger.debug(f"API: Edit hook module found but no on_config_save function")
|
||||
except ModuleNotFoundError:
|
||||
logger.debug(f"API: No edit_hook module for processor {processor_name} (this is normal)")
|
||||
except Exception as hook_error:
|
||||
logger.error(f"API: Edit hook error (non-fatal): {hook_error}", exc_info=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"API: Failed to save processor config: {e}")
|
||||
# Save processor config to JSON file
|
||||
processors.save_processor_config(self.datastore, uuid, processor_config_data)
|
||||
|
||||
return "OK", 200
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
{% from '_helpers.html' import render_simple_field, render_field %}
|
||||
<div class="edit-form">
|
||||
<div class="box-wrap inner">
|
||||
<h4>{{ _('Backups') }}</h4>
|
||||
<h2>{{ _('Backups') }}</h2>
|
||||
{% if backup_running %}
|
||||
<p>
|
||||
<strong>{{ _('A backup is running!') }}</strong>
|
||||
<span class="spinner"></span> <strong>{{ _('A backup is running!') }}</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>
|
||||
|
||||
@@ -26,8 +26,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
# URL List import
|
||||
if request.values.get('urls') and len(request.values.get('urls').strip()):
|
||||
# Import and push into the queue for immediate update check
|
||||
from changedetectionio import processors
|
||||
importer_handler = import_url_list()
|
||||
importer_handler.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff'))
|
||||
importer_handler.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', processors.get_default_processor()))
|
||||
logger.debug(f"Imported {len(importer_handler.new_uuids)} new UUIDs")
|
||||
# Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue
|
||||
# for uuid in importer_handler.new_uuids:
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<li class="tab"><a href="#ui-options">{{ _('UI Options') }}</a></li>
|
||||
<li class="tab"><a href="#api">{{ _('API') }}</a></li>
|
||||
<li class="tab"><a href="#rss">{{ _('RSS') }}</a></li>
|
||||
<li class="tab"><a href="{{ url_for('backups.index') }}" class="pure-menu-link">{{ _('Backups') }}</a></li>
|
||||
<li class="tab"><a href="{{ url_for('backups.index') }}">{{ _('Backups') }}</a></li>
|
||||
<li class="tab"><a href="#timedate">{{ _('Time & Date') }}</a></li>
|
||||
<li class="tab"><a href="#proxies">{{ _('CAPTCHA & Proxies') }}</a></li>
|
||||
{% if plugin_tabs %}
|
||||
@@ -59,6 +59,14 @@
|
||||
{{ _('Set to') }} <strong>0</strong> {{ _('to disable') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.application.form.history_snapshot_max_length, class="history_snapshot_max_length") }}
|
||||
<span class="pure-form-message-inline">{{ _('Limit collection of history snapshots for each watch to this number of history items.') }}
|
||||
<br>
|
||||
{{ _('Set to empty to disable / no limit') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{% if not hide_remove_pass %}
|
||||
{% if current_user.is_authenticated %}
|
||||
|
||||
@@ -100,23 +100,21 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# Get the processor type for this watch
|
||||
processor_name = watch.get('processor', 'text_json_diff')
|
||||
|
||||
try:
|
||||
# Try to import the processor's difference module
|
||||
processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.difference')
|
||||
# Try to get the processor's difference module (works for both built-in and plugin processors)
|
||||
from changedetectionio.processors import get_processor_submodule
|
||||
processor_module = get_processor_submodule(processor_name, 'difference')
|
||||
|
||||
# Call the processor's render() function
|
||||
if hasattr(processor_module, 'render'):
|
||||
return processor_module.render(
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request,
|
||||
url_for=url_for,
|
||||
render_template=render_template,
|
||||
flash=flash,
|
||||
redirect=redirect
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.warning(f"Processor {processor_name} does not have a difference module, falling back to text_json_diff: {e}")
|
||||
# Call the processor's render() function
|
||||
if processor_module and hasattr(processor_module, 'render'):
|
||||
return processor_module.render(
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request,
|
||||
url_for=url_for,
|
||||
render_template=render_template,
|
||||
flash=flash,
|
||||
redirect=redirect
|
||||
)
|
||||
|
||||
# Fallback: if processor doesn't have difference module, use text_json_diff as default
|
||||
from changedetectionio.processors.text_json_diff.difference import render as default_render
|
||||
@@ -156,23 +154,21 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# Get the processor type for this watch
|
||||
processor_name = watch.get('processor', 'text_json_diff')
|
||||
|
||||
try:
|
||||
# Try to import the processor's extract module
|
||||
processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.extract')
|
||||
# Try to get the processor's extract module (works for both built-in and plugin processors)
|
||||
from changedetectionio.processors import get_processor_submodule
|
||||
processor_module = get_processor_submodule(processor_name, 'extract')
|
||||
|
||||
# Call the processor's render_form() function
|
||||
if hasattr(processor_module, 'render_form'):
|
||||
return processor_module.render_form(
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request,
|
||||
url_for=url_for,
|
||||
render_template=render_template,
|
||||
flash=flash,
|
||||
redirect=redirect
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.warning(f"Processor {processor_name} does not have an extract module, falling back to base extractor: {e}")
|
||||
# Call the processor's render_form() function
|
||||
if processor_module and hasattr(processor_module, 'render_form'):
|
||||
return processor_module.render_form(
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request,
|
||||
url_for=url_for,
|
||||
render_template=render_template,
|
||||
flash=flash,
|
||||
redirect=redirect
|
||||
)
|
||||
|
||||
# Fallback: if processor doesn't have extract module, use base processors.extract as default
|
||||
from changedetectionio.processors.extract import render_form as default_render_form
|
||||
@@ -212,24 +208,22 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# Get the processor type for this watch
|
||||
processor_name = watch.get('processor', 'text_json_diff')
|
||||
|
||||
try:
|
||||
# Try to import the processor's extract module
|
||||
processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.extract')
|
||||
# Try to get the processor's extract module (works for both built-in and plugin processors)
|
||||
from changedetectionio.processors import get_processor_submodule
|
||||
processor_module = get_processor_submodule(processor_name, 'extract')
|
||||
|
||||
# Call the processor's process_extraction() function
|
||||
if hasattr(processor_module, 'process_extraction'):
|
||||
return processor_module.process_extraction(
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request,
|
||||
url_for=url_for,
|
||||
make_response=make_response,
|
||||
send_from_directory=send_from_directory,
|
||||
flash=flash,
|
||||
redirect=redirect
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.warning(f"Processor {processor_name} does not have an extract module, falling back to base extractor: {e}")
|
||||
# Call the processor's process_extraction() function
|
||||
if processor_module and hasattr(processor_module, 'process_extraction'):
|
||||
return processor_module.process_extraction(
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request,
|
||||
url_for=url_for,
|
||||
make_response=make_response,
|
||||
send_from_directory=send_from_directory,
|
||||
flash=flash,
|
||||
redirect=redirect
|
||||
)
|
||||
|
||||
# Fallback: if processor doesn't have extract module, use base processors.extract as default
|
||||
from changedetectionio.processors.extract import process_extraction as default_process_extraction
|
||||
@@ -279,38 +273,33 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# Get the processor type for this watch
|
||||
processor_name = watch.get('processor', 'text_json_diff')
|
||||
|
||||
try:
|
||||
# Try to import the processor's difference module
|
||||
processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.difference')
|
||||
# Try to get the processor's difference module (works for both built-in and plugin processors)
|
||||
from changedetectionio.processors import get_processor_submodule
|
||||
processor_module = get_processor_submodule(processor_name, 'difference')
|
||||
|
||||
# Call the processor's get_asset() function
|
||||
if hasattr(processor_module, 'get_asset'):
|
||||
result = processor_module.get_asset(
|
||||
asset_name=asset_name,
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request
|
||||
)
|
||||
# Call the processor's get_asset() function
|
||||
if processor_module and hasattr(processor_module, 'get_asset'):
|
||||
result = processor_module.get_asset(
|
||||
asset_name=asset_name,
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request
|
||||
)
|
||||
|
||||
if result is None:
|
||||
from flask import abort
|
||||
abort(404, description=f"Asset '{asset_name}' not found")
|
||||
|
||||
binary_data, content_type, cache_control = result
|
||||
|
||||
response = make_response(binary_data)
|
||||
response.headers['Content-Type'] = content_type
|
||||
if cache_control:
|
||||
response.headers['Cache-Control'] = cache_control
|
||||
return response
|
||||
else:
|
||||
logger.warning(f"Processor {processor_name} does not implement get_asset()")
|
||||
if result is None:
|
||||
from flask import abort
|
||||
abort(404, description=f"Processor '{processor_name}' does not support assets")
|
||||
abort(404, description=f"Asset '{asset_name}' not found")
|
||||
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.warning(f"Processor {processor_name} does not have a difference module: {e}")
|
||||
binary_data, content_type, cache_control = result
|
||||
|
||||
response = make_response(binary_data)
|
||||
response.headers['Content-Type'] = content_type
|
||||
if cache_control:
|
||||
response.headers['Cache-Control'] = cache_control
|
||||
return response
|
||||
else:
|
||||
logger.warning(f"Processor {processor_name} does not implement get_asset()")
|
||||
from flask import abort
|
||||
abort(404, description=f"Processor '{processor_name}' not found")
|
||||
abort(404, description=f"Processor '{processor_name}' does not support assets")
|
||||
|
||||
return diff_blueprint
|
||||
|
||||
@@ -71,8 +71,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
processor_name = datastore.data['watching'][uuid].get('processor', '')
|
||||
processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == processor_name), None)
|
||||
if not processor_classes:
|
||||
flash(gettext("Cannot load the edit form for processor/plugin '{}', plugin missing?").format(processor_classes[1]), 'error')
|
||||
return redirect(url_for('watchlist.index'))
|
||||
flash(gettext("Could not load '{}' processor, processor plugin might be missing. Please select a different processor.").format(processor_name), 'error')
|
||||
# Fall back to default processor so user can still edit and change processor
|
||||
processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == 'text_json_diff'), None)
|
||||
if not processor_classes:
|
||||
# If even text_json_diff is missing, something is very wrong
|
||||
flash(gettext("Could not load '{}' processor, processor plugin might be missing.").format(processor_name), 'error')
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
parent_module = processors.get_parent_module(processor_classes[0])
|
||||
|
||||
@@ -149,58 +154,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
|
||||
extra_update_obj['time_between_check'] = form.time_between_check.data
|
||||
|
||||
# Handle processor-config-* fields separately (save to JSON, not datastore)
|
||||
processor_config_data = {}
|
||||
fields_to_remove = []
|
||||
for field_name, field_value in form.data.items():
|
||||
if field_name.startswith('processor_config_'):
|
||||
config_key = field_name.replace('processor_config_', '')
|
||||
if field_value: # Only save non-empty values
|
||||
processor_config_data[config_key] = field_value
|
||||
fields_to_remove.append(field_name)
|
||||
|
||||
# Save processor config to JSON file if any config data exists
|
||||
if processor_config_data:
|
||||
try:
|
||||
processor_name = form.data.get('processor')
|
||||
# Create a processor instance to access config methods
|
||||
processor_instance = processors.difference_detection_processor(datastore, uuid)
|
||||
# Use processor name as filename so each processor keeps its own config
|
||||
config_filename = f'{processor_name}.json'
|
||||
processor_instance.update_extra_watch_config(config_filename, processor_config_data)
|
||||
logger.debug(f"Saved processor config to {config_filename}: {processor_config_data}")
|
||||
|
||||
# Call optional edit_hook if processor has one
|
||||
try:
|
||||
# Try to import the edit_hook module from the processor package
|
||||
import importlib
|
||||
edit_hook_module_name = f'changedetectionio.processors.{processor_name}.edit_hook'
|
||||
|
||||
try:
|
||||
edit_hook = importlib.import_module(edit_hook_module_name)
|
||||
logger.debug(f"Found edit_hook module for {processor_name}")
|
||||
|
||||
if hasattr(edit_hook, 'on_config_save'):
|
||||
logger.info(f"Calling edit_hook.on_config_save for {processor_name}")
|
||||
watch_obj = datastore.data['watching'][uuid]
|
||||
# Call hook and get updated config
|
||||
updated_config = edit_hook.on_config_save(watch_obj, processor_config_data, datastore)
|
||||
# Save updated config back to file
|
||||
processor_instance.update_extra_watch_config(config_filename, updated_config)
|
||||
logger.info(f"Edit hook updated config: {updated_config}")
|
||||
else:
|
||||
logger.debug(f"Edit hook module found but no on_config_save function")
|
||||
except ModuleNotFoundError:
|
||||
logger.debug(f"No edit_hook module for processor {processor_name} (this is normal)")
|
||||
except Exception as hook_error:
|
||||
logger.error(f"Edit hook error (non-fatal): {hook_error}", exc_info=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save processor config: {e}")
|
||||
|
||||
# Remove processor-config-* fields from form.data before updating datastore
|
||||
for field_name in fields_to_remove:
|
||||
form.data.pop(field_name, None)
|
||||
# Handle processor-config-* fields separately (save to JSON, not datastore)
|
||||
# IMPORTANT: These must NOT be saved to url-watches.json, only to the processor-specific JSON file
|
||||
processor_config_data = processors.extract_processor_config_from_form_data(form.data)
|
||||
processors.save_processor_config(datastore, uuid, processor_config_data)
|
||||
|
||||
# Ignore text
|
||||
form_ignore_text = form.ignore_text.data
|
||||
@@ -240,7 +197,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
|
||||
# Recast it if need be to right data Watch handler
|
||||
watch_class = processors.get_custom_watch_obj_for_processor(form.data.get('processor'))
|
||||
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid])
|
||||
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, __datastore=datastore.data, default=datastore.data['watching'][uuid])
|
||||
flash(gettext("Updated watch - unpaused!") if request.args.get('unpause_on_save') else gettext("Updated watch."))
|
||||
|
||||
# Cleanup any browsersteps session for this watch
|
||||
@@ -310,6 +267,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
|
||||
# Get fetcher capabilities instead of hardcoded logic
|
||||
capabilities = get_fetcher_capabilities(watch, datastore)
|
||||
|
||||
# Add processor capabilities from module
|
||||
capabilities['supports_visual_selector'] = getattr(parent_module, 'supports_visual_selector', False)
|
||||
capabilities['supports_text_filters_and_triggers'] = getattr(parent_module, 'supports_text_filters_and_triggers', False)
|
||||
capabilities['supports_text_filters_and_triggers_elements'] = getattr(parent_module, 'supports_text_filters_and_triggers_elements', False)
|
||||
capabilities['supports_request_type'] = getattr(parent_module, 'supports_request_type', False)
|
||||
|
||||
app_rss_token = datastore.data['settings']['application'].get('rss_access_token'),
|
||||
|
||||
c = [f"processor-{watch.get('processor')}"]
|
||||
|
||||
@@ -38,24 +38,21 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# Get the processor type for this watch
|
||||
processor_name = watch.get('processor', 'text_json_diff')
|
||||
|
||||
try:
|
||||
# Try to import the processor's preview module
|
||||
import importlib
|
||||
processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.preview')
|
||||
# Try to get the processor's preview module (works for both built-in and plugin processors)
|
||||
from changedetectionio.processors import get_processor_submodule
|
||||
processor_module = get_processor_submodule(processor_name, 'preview')
|
||||
|
||||
# Call the processor's render() function
|
||||
if hasattr(processor_module, 'render'):
|
||||
return processor_module.render(
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request,
|
||||
url_for=url_for,
|
||||
render_template=render_template,
|
||||
flash=flash,
|
||||
redirect=redirect
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.debug(f"Processor {processor_name} does not have a preview module, using default preview: {e}")
|
||||
# Call the processor's render() function
|
||||
if processor_module and hasattr(processor_module, 'render'):
|
||||
return processor_module.render(
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request,
|
||||
url_for=url_for,
|
||||
render_template=render_template,
|
||||
flash=flash,
|
||||
redirect=redirect
|
||||
)
|
||||
|
||||
# Fallback: if processor doesn't have preview module, use default text preview
|
||||
content = []
|
||||
@@ -160,39 +157,33 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# Get the processor type for this watch
|
||||
processor_name = watch.get('processor', 'text_json_diff')
|
||||
|
||||
try:
|
||||
# Try to import the processor's preview module
|
||||
import importlib
|
||||
processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.preview')
|
||||
# Try to get the processor's preview module (works for both built-in and plugin processors)
|
||||
from changedetectionio.processors import get_processor_submodule
|
||||
processor_module = get_processor_submodule(processor_name, 'preview')
|
||||
|
||||
# Call the processor's get_asset() function
|
||||
if hasattr(processor_module, 'get_asset'):
|
||||
result = processor_module.get_asset(
|
||||
asset_name=asset_name,
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request
|
||||
)
|
||||
# Call the processor's get_asset() function
|
||||
if processor_module and hasattr(processor_module, 'get_asset'):
|
||||
result = processor_module.get_asset(
|
||||
asset_name=asset_name,
|
||||
watch=watch,
|
||||
datastore=datastore,
|
||||
request=request
|
||||
)
|
||||
|
||||
if result is None:
|
||||
from flask import abort
|
||||
abort(404, description=f"Asset '{asset_name}' not found")
|
||||
|
||||
binary_data, content_type, cache_control = result
|
||||
|
||||
response = make_response(binary_data)
|
||||
response.headers['Content-Type'] = content_type
|
||||
if cache_control:
|
||||
response.headers['Cache-Control'] = cache_control
|
||||
return response
|
||||
else:
|
||||
logger.warning(f"Processor {processor_name} does not implement get_asset()")
|
||||
if result is None:
|
||||
from flask import abort
|
||||
abort(404, description=f"Processor '{processor_name}' does not support assets")
|
||||
abort(404, description=f"Asset '{asset_name}' not found")
|
||||
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.warning(f"Processor {processor_name} does not have a preview module: {e}")
|
||||
binary_data, content_type, cache_control = result
|
||||
|
||||
response = make_response(binary_data)
|
||||
response.headers['Content-Type'] = content_type
|
||||
if cache_control:
|
||||
response.headers['Cache-Control'] = cache_control
|
||||
return response
|
||||
else:
|
||||
logger.warning(f"Processor {processor_name} does not implement get_asset()")
|
||||
from flask import abort
|
||||
abort(404, description=f"Processor '{processor_name}' not found")
|
||||
abort(404, description=f"Processor '{processor_name}' does not support assets")
|
||||
|
||||
return preview_blueprint
|
||||
|
||||
@@ -45,14 +45,19 @@
|
||||
<div class="tabs collapsable">
|
||||
<ul>
|
||||
<li class="tab"><a href="#general">{{ _('General') }}</a></li>
|
||||
{% if capabilities.supports_request_type %}
|
||||
<li class="tab"><a href="#request">{{ _('Request') }}</a></li>
|
||||
{% endif %}
|
||||
{% if extra_tab_content %}
|
||||
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
|
||||
{% endif %}
|
||||
{% if capabilities.supports_browser_steps %}
|
||||
<li class="tab"><a id="browsersteps-tab" href="#browser-steps">{{ _('Browser Steps') }}</a></li>
|
||||
<!-- should goto extra forms? -->
|
||||
{% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'image_ssim_diff' %}
|
||||
{% endif %}
|
||||
{% if capabilities.supports_visual_selector %}
|
||||
<li class="tab"><a id="visualselector-tab" href="#visualselector">{{ _('Visual Filter Selector') }}</a></li>
|
||||
{% endif %}
|
||||
{% if capabilities.supports_text_filters_and_triggers %}
|
||||
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
|
||||
<li class="tab" id="conditions-tab"><a href="#conditions">{{ _('Conditions') }}</a></li>
|
||||
{% endif %}
|
||||
@@ -110,12 +115,20 @@
|
||||
{{ _('Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore.') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.history_snapshot_max_length, class="history_snapshot_max_length") }}
|
||||
<span class="pure-form-message-inline">{{ _('Limit collection of history snapshots for each watch to this number of history items.') }}
|
||||
<br>
|
||||
{{ _('Set to empty to use system settings default') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_ternary_field(form.use_page_title_in_list) }}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{% if capabilities.supports_request_type %}
|
||||
<div class="tab-pane-inner" id="request">
|
||||
<div class="pure-control-group inline-radio">
|
||||
{{ render_field(form.fetch_backend, class="fetch-backend") }}
|
||||
@@ -203,6 +216,7 @@ Math: {{ 1 + 1 }}") }}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="tab-pane-inner" id="browser-steps">
|
||||
{% if capabilities.supports_browser_steps %}
|
||||
@@ -283,8 +297,7 @@ Math: {{ 1 + 1 }}") }}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'image_ssim_diff' %}
|
||||
|
||||
{% if capabilities.supports_text_filters_and_triggers %}
|
||||
<div class="tab-pane-inner" id="conditions">
|
||||
<script>
|
||||
const verify_condition_rule_url="{{url_for('conditions.verify_condition_single_rule', watch_uuid=uuid)}}";
|
||||
@@ -303,7 +316,9 @@ Math: {{ 1 + 1 }}") }}
|
||||
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">{{ _('Activate preview') }}</span>
|
||||
<div>
|
||||
<div id="edit-text-filter">
|
||||
<div class="pure-control-group" id="pro-tips">
|
||||
|
||||
{% if capabilities.supports_text_filters_and_triggers_elements %}
|
||||
<div class="pure-control-group" id="pro-tips">
|
||||
<strong>{{ _('Pro-tips:') }}</strong><br>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -314,8 +329,8 @@ Math: {{ 1 + 1 }}") }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% include "edit/include_subtract.html" %}
|
||||
{% endif %}
|
||||
<div class="text-filtering border-fieldset">
|
||||
<fieldset class="pure-group" id="text-filtering-type-options">
|
||||
<h3>{{ _('Text filtering') }}</h3>
|
||||
@@ -374,7 +389,7 @@ Math: {{ 1 + 1 }}") }}
|
||||
{{ extra_form_content|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'image_ssim_diff' %}
|
||||
{% if capabilities.supports_visual_selector %}
|
||||
<div class="tab-pane-inner visual-selector-ui" id="visualselector">
|
||||
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">
|
||||
|
||||
@@ -386,7 +401,7 @@ Math: {{ 1 + 1 }}") }}
|
||||
{{ _('The Visual Selector tool lets you select the') }} <i>{{ _('text') }}</i> {{ _('elements that will be used for the change detection. It automatically fills-in the filters in the "CSS/JSONPath/JQ/XPath Filters" box of the') }} <a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a> {{ _('tab. Use') }} <strong>{{ _('Shift+Click') }}</strong> {{ _('to select multiple items.') }}
|
||||
</span>
|
||||
|
||||
{% if watch['processor'] == 'image_ssim_diff' %}
|
||||
{% if watch['processor'] == 'image_ssim_diff' %} {# @todo, integrate with image_ssim_diff selector better, use some extra form ? #}
|
||||
<div id="selection-mode-controls" style="margin: 10px 0; padding: 10px; background: var(--color-background-tab); border-radius: 5px;">
|
||||
<label style="font-weight: 600; margin-right: 15px;">{{ _('Selection Mode:') }}</label>
|
||||
<label style="margin-right: 15px;">
|
||||
|
||||
@@ -24,8 +24,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
flash(gettext('Warning, URL {} already exists').format(url), "notice")
|
||||
|
||||
add_paused = request.form.get('edit_and_watch_submit_button') != None
|
||||
processor = request.form.get('processor', 'text_json_diff')
|
||||
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor})
|
||||
from changedetectionio import processors
|
||||
processor = request.form.get('processor', processors.get_default_processor())
|
||||
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags','').strip(), extras={'paused': add_paused, 'processor': processor})
|
||||
|
||||
if new_uuid:
|
||||
if add_paused:
|
||||
@@ -38,4 +39,4 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
|
||||
return redirect(url_for('watchlist.index', tag=request.args.get('tag','')))
|
||||
|
||||
return views_blueprint
|
||||
return views_blueprint
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block content -%}
|
||||
{%- set tips = [
|
||||
_("Changedetection.io can monitor more than just web-pages! See our plugins!") ~ ' <a href="https://changedetection.io/plugins">' ~ _('More info') ~ '</a>',
|
||||
_("You can also add 'shared' watches.") ~ ' <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">' ~ _('More info') ~ '</a>'
|
||||
] -%}
|
||||
{%- from '_helpers.html' import render_simple_field, render_field, render_nolabel_field, sort_by_title -%}
|
||||
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script>
|
||||
@@ -10,6 +14,46 @@
|
||||
// Initialize Feather icons after the page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
feather.replace();
|
||||
|
||||
// Intersection Observer for lazy loading favicons
|
||||
// Only load favicon images when they enter the viewport
|
||||
if ('IntersectionObserver' in window) {
|
||||
const faviconObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
const src = img.getAttribute('data-src');
|
||||
|
||||
if (src) {
|
||||
// Load the actual favicon
|
||||
img.src = src;
|
||||
img.removeAttribute('data-src');
|
||||
}
|
||||
|
||||
// Stop observing this image
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
// Start loading slightly before the image enters viewport
|
||||
rootMargin: '50px',
|
||||
threshold: 0.01
|
||||
});
|
||||
|
||||
// Observe all lazy favicon images
|
||||
document.querySelectorAll('.lazy-favicon').forEach(img => {
|
||||
faviconObserver.observe(img);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers: load all favicons immediately
|
||||
document.querySelectorAll('.lazy-favicon').forEach(img => {
|
||||
const src = img.getAttribute('data-src');
|
||||
if (src) {
|
||||
img.src = src;
|
||||
img.removeAttribute('data-src');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
@@ -69,7 +113,9 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
<span style="color:#eee; font-size: 80%;"><img alt="{{ _('Create a shareable link') }}" style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" > {{ _("Tip: You can also add 'shared' watches.") }} <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">{{ _('More info') }}</a></span>
|
||||
<span style="color:#eee; font-size: 80%;">
|
||||
<strong>Tip: </strong> {{ tips | random | safe }}
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
<div class="box">
|
||||
@@ -200,28 +246,38 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
<td class="title-col inline">
|
||||
<div class="flex-wrapper">
|
||||
{% if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] %}
|
||||
<div>{# A page might have hundreds of these images, set IMG options for lazy loading, don't set SRC if we dont have it so it doesnt fetch the placeholder' #}
|
||||
<img alt="Favicon thumbnail" class="favicon" loading="lazy" decoding="async" fetchpriority="low" {% if favicon %} src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}" {% else %} src='data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="7.087" height="7.087" viewBox="0 0 7.087 7.087"%3E%3Ccircle cx="3.543" cy="3.543" r="3.279" stroke="%23e1e1e1" stroke-width="0.45" fill="none" opacity="0.74"/%3E%3C/svg%3E' {% endif %} >
|
||||
<div>
|
||||
{# Intersection Observer lazy loading: store real URL in data-src, load only when visible in viewport #}
|
||||
<img alt="Favicon thumbnail"
|
||||
class="favicon lazy-favicon"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
{% if favicon %}
|
||||
data-src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}"
|
||||
{% endif %}
|
||||
src='data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="7.087" height="7.087" viewBox="0 0 7.087 7.087"%3E%3Ccircle cx="3.543" cy="3.543" r="3.279" stroke="%23e1e1e1" stroke-width="0.45" fill="none" opacity="0.74"/%3E%3C/svg%3E'>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<span class="watch-title">
|
||||
{% if system_use_url_watchlist or watch.get('use_page_title_in_list') %}
|
||||
{{ watch.label }}
|
||||
{% else %}
|
||||
{{ watch.get('title') or watch.link }}
|
||||
{% endif %}
|
||||
<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"> </a>
|
||||
</span>
|
||||
{%- if watch['processor'] and watch['processor'] in processor_badge_texts -%}
|
||||
<span class="processor-badge processor-badge-{{ watch['processor'] }}" title="{{ processor_descriptions.get(watch['processor'], watch['processor']) }}">{{ processor_badge_texts[watch['processor']] }}</span>
|
||||
{%- endif -%}
|
||||
<span class="watch-title">
|
||||
{% if system_use_url_watchlist or watch.get('use_page_title_in_list') %}
|
||||
{{ watch.label }}
|
||||
{% else %}
|
||||
{{ watch.get('title') or watch.link }}
|
||||
{% endif %}
|
||||
<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"> </a>
|
||||
</span>
|
||||
<div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list)|safe }}</div>
|
||||
{%- if watch['processor'] == 'text_json_diff' -%}
|
||||
{%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%}
|
||||
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- if watch['processor'] and watch['processor'] in processor_badge_texts -%}
|
||||
<span class="processor-badge processor-badge-{{ watch['processor'] }}" title="{{ processor_descriptions.get(watch['processor'], watch['processor']) }}">{{ processor_badge_texts[watch['processor']] }}</span>
|
||||
{%- endif -%}
|
||||
|
||||
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
|
||||
<a href="{{url_for('watchlist.index', tag=watch_tag_uuid) }}" class="watch-tag-list tag-{{ watch_tag.title|sanitize_tag_class }}">{{ watch_tag.title }}</a>
|
||||
{%- endfor -%}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
@@ -349,12 +350,7 @@ class fetcher(Fetcher):
|
||||
|
||||
if self.status_code != 200 and not ignore_status_codes:
|
||||
screenshot = await capture_full_page_async(self.page, screenshot_format=self.screenshot_format, watch_uuid=watch_uuid, lock_viewport_elements=self.lock_viewport_elements)
|
||||
# Cleanup before raising to prevent memory leak
|
||||
await self.page.close()
|
||||
await context.close()
|
||||
await browser.close()
|
||||
# Force garbage collection to release Playwright resources immediately
|
||||
gc.collect()
|
||||
# Finally block will handle cleanup
|
||||
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
|
||||
|
||||
if not empty_pages_are_a_change and len((await self.page.content()).strip()) == 0:
|
||||
@@ -370,12 +366,7 @@ class fetcher(Fetcher):
|
||||
try:
|
||||
await self.iterate_browser_steps(start_url=url)
|
||||
except BrowserStepsStepException:
|
||||
try:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
except Exception as e:
|
||||
# Fine, could be messy situation
|
||||
pass
|
||||
# Finally block will handle cleanup
|
||||
raise
|
||||
|
||||
await self.page.wait_for_timeout(extra_wait * 1000)
|
||||
@@ -424,35 +415,40 @@ class fetcher(Fetcher):
|
||||
raise ScreenshotUnavailable(url=url, status_code=self.status_code)
|
||||
|
||||
finally:
|
||||
# Request garbage collection one more time before closing
|
||||
# Clean up resources properly with timeouts to prevent hanging
|
||||
try:
|
||||
await self.page.request_gc()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Clean up resources properly
|
||||
try:
|
||||
await self.page.request_gc()
|
||||
except:
|
||||
pass
|
||||
if hasattr(self, 'page') and self.page:
|
||||
await self.page.request_gc()
|
||||
await asyncio.wait_for(self.page.close(), timeout=5.0)
|
||||
logger.debug(f"Successfully closed page for {url}")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Timed out closing page for {url} (5s)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing page for {url}: {e}")
|
||||
finally:
|
||||
self.page = None
|
||||
|
||||
try:
|
||||
await self.page.close()
|
||||
except:
|
||||
pass
|
||||
self.page = None
|
||||
if context:
|
||||
await asyncio.wait_for(context.close(), timeout=5.0)
|
||||
logger.debug(f"Successfully closed context for {url}")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Timed out closing context for {url} (5s)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing context for {url}: {e}")
|
||||
finally:
|
||||
context = None
|
||||
|
||||
try:
|
||||
await context.close()
|
||||
except:
|
||||
pass
|
||||
context = None
|
||||
|
||||
try:
|
||||
await browser.close()
|
||||
except:
|
||||
pass
|
||||
browser = None
|
||||
if browser:
|
||||
await asyncio.wait_for(browser.close(), timeout=5.0)
|
||||
logger.debug(f"Successfully closed browser connection for {url}")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Timed out closing browser connection for {url} (5s)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing browser for {url}: {e}")
|
||||
finally:
|
||||
browser = None
|
||||
|
||||
# Force Python GC to release Playwright resources immediately
|
||||
# Playwright objects can have circular references that delay cleanup
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
import websockets.exceptions
|
||||
@@ -221,19 +222,36 @@ class fetcher(Fetcher):
|
||||
self.browser_connection_url += f"{r}--proxy-server={proxy_url}"
|
||||
|
||||
async def quit(self, watch=None):
|
||||
try:
|
||||
await self.page.close()
|
||||
del self.page
|
||||
except Exception as e:
|
||||
pass
|
||||
watch_uuid = watch.get('uuid') if watch else 'unknown'
|
||||
|
||||
# Close page
|
||||
try:
|
||||
await self.browser.close()
|
||||
del self.browser
|
||||
if hasattr(self, 'page') and self.page:
|
||||
await asyncio.wait_for(self.page.close(), timeout=5.0)
|
||||
logger.debug(f"[{watch_uuid}] Page closed successfully")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"[{watch_uuid}] Timed out closing page (5s)")
|
||||
except Exception as e:
|
||||
pass
|
||||
logger.warning(f"[{watch_uuid}] Error closing page: {e}")
|
||||
finally:
|
||||
self.page = None
|
||||
|
||||
logger.info("Cleanup puppeteer complete.")
|
||||
# Close browser connection
|
||||
try:
|
||||
if hasattr(self, 'browser') and self.browser:
|
||||
await asyncio.wait_for(self.browser.close(), timeout=5.0)
|
||||
logger.debug(f"[{watch_uuid}] Browser closed successfully")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"[{watch_uuid}] Timed out closing browser (5s)")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{watch_uuid}] Error closing browser: {e}")
|
||||
finally:
|
||||
self.browser = None
|
||||
|
||||
logger.info(f"[{watch_uuid}] Cleanup puppeteer complete")
|
||||
|
||||
# Force garbage collection to release resources
|
||||
gc.collect()
|
||||
|
||||
async def fetch_page(self,
|
||||
current_include_filters,
|
||||
@@ -263,9 +281,11 @@ class fetcher(Fetcher):
|
||||
# Connect directly using the specified browser_ws_endpoint
|
||||
# @todo timeout
|
||||
try:
|
||||
logger.debug(f"[{watch_uuid}] Connecting to browser at {self.browser_connection_url}")
|
||||
self.browser = await pyppeteer_instance.connect(browserWSEndpoint=self.browser_connection_url,
|
||||
ignoreHTTPSErrors=True
|
||||
)
|
||||
logger.debug(f"[{watch_uuid}] Browser connected successfully")
|
||||
except websockets.exceptions.InvalidStatusCode as e:
|
||||
raise BrowserConnectError(msg=f"Error while trying to connect the browser, Code {e.status_code} (check your access, whitelist IP, password etc)")
|
||||
except websockets.exceptions.InvalidURI:
|
||||
@@ -274,7 +294,18 @@ class fetcher(Fetcher):
|
||||
raise BrowserConnectError(msg=f"Error connecting to the browser - Exception '{str(e)}'")
|
||||
|
||||
# more reliable is to just request a new page
|
||||
self.page = await self.browser.newPage()
|
||||
try:
|
||||
logger.debug(f"[{watch_uuid}] Creating new page")
|
||||
self.page = await self.browser.newPage()
|
||||
logger.debug(f"[{watch_uuid}] Page created successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"[{watch_uuid}] Failed to create new page: {e}")
|
||||
# Browser is connected but page creation failed - must cleanup browser
|
||||
try:
|
||||
await asyncio.wait_for(self.browser.close(), timeout=3.0)
|
||||
except Exception as cleanup_error:
|
||||
logger.error(f"[{watch_uuid}] Failed to cleanup browser after page creation failure: {cleanup_error}")
|
||||
raise
|
||||
|
||||
# Add console handler to capture console.log from favicon fetcher
|
||||
#self.page.on('console', lambda msg: logger.debug(f"Browser console [{msg.type}]: {msg.text}"))
|
||||
@@ -343,6 +374,12 @@ class fetcher(Fetcher):
|
||||
w = extra_wait - 2 if extra_wait > 4 else 2
|
||||
logger.debug(f"Waiting {w} seconds before calling Page.stopLoading...")
|
||||
await asyncio.sleep(w)
|
||||
|
||||
# Check if page still exists (might have been closed due to error during sleep)
|
||||
if not self.page or not hasattr(self.page, '_client'):
|
||||
logger.debug("Page already closed, skipping stopLoading")
|
||||
return
|
||||
|
||||
logger.debug("Issuing stopLoading command...")
|
||||
await self.page._client.send('Page.stopLoading')
|
||||
logger.debug("stopLoading command sent!")
|
||||
@@ -368,7 +405,9 @@ class fetcher(Fetcher):
|
||||
asyncio.create_task(handle_frame_navigation())
|
||||
response = await self.page.goto(url, timeout=0)
|
||||
await asyncio.sleep(1 + extra_wait)
|
||||
await self.page._client.send('Page.stopLoading')
|
||||
# Check if page still exists before sending command
|
||||
if self.page and hasattr(self.page, '_client'):
|
||||
await self.page._client.send('Page.stopLoading')
|
||||
|
||||
if response:
|
||||
break
|
||||
@@ -437,15 +476,9 @@ class fetcher(Fetcher):
|
||||
logger.debug(f"Screenshot format {self.screenshot_format}")
|
||||
self.screenshot = await capture_full_page(page=self.page, screenshot_format=self.screenshot_format, watch_uuid=watch_uuid, lock_viewport_elements=self.lock_viewport_elements)
|
||||
|
||||
# Force aggressive memory cleanup - pyppeteer base64 decode creates temporary buffers
|
||||
# Force garbage collection - pyppeteer base64 decode creates temporary buffers
|
||||
import gc
|
||||
gc.collect()
|
||||
# Release C-level memory from base64 decode back to OS
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.CDLL('libc.so.6').malloc_trim(0)
|
||||
except Exception:
|
||||
pass
|
||||
self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
|
||||
"visualselector_xpath_selectors": visualselector_xpath_selectors,
|
||||
"max_height": MAX_TOTAL_HEIGHT
|
||||
|
||||
@@ -730,7 +730,7 @@ class quickWatchForm(Form):
|
||||
url = fields.URLField(_l('URL'), validators=[validateURL()])
|
||||
tags = StringTagUUID(_l('Group tag'), validators=[validators.Optional()])
|
||||
watch_submit_button = SubmitField(_l('Watch'), render_kw={"class": "pure-button pure-button-primary"})
|
||||
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default="text_json_diff")
|
||||
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
|
||||
edit_and_watch_submit_button = SubmitField(_l('Edit > Watch'), render_kw={"class": "pure-button pure-button-primary"})
|
||||
|
||||
|
||||
@@ -749,7 +749,7 @@ class commonSettingsForm(Form):
|
||||
notification_format = SelectField(_l('Notification format'), choices=list(valid_notification_formats.items()))
|
||||
notification_title = StringField(_l('Notification Title'), default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])
|
||||
notification_urls = StringListField(_l('Notification URL List'), validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()])
|
||||
processor = RadioField( label=_l("Processor - What do you want to achieve?"), choices=lambda: processors.available_processors(), default="text_json_diff")
|
||||
processor = RadioField( label=_l("Processor - What do you want to achieve?"), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
|
||||
scheduler_timezone_default = StringField(_l("Default timezone for watch check scheduler"), render_kw={"list": "timezones"}, validators=[validateTimeZoneName()])
|
||||
webdriver_delay = IntegerField(_l('Wait seconds before extracting text'), validators=[validators.Optional(), validators.NumberRange(min=1, message=_l("Should contain one or more seconds"))])
|
||||
|
||||
@@ -763,7 +763,7 @@ class commonSettingsForm(Form):
|
||||
|
||||
|
||||
class importForm(Form):
|
||||
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default="text_json_diff")
|
||||
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
|
||||
urls = TextAreaField(_l('URLs'))
|
||||
xlsx_file = FileField(_l('Upload .xlsx file'), validators=[FileAllowed(['xlsx'], _l('Must be .xlsx file!'))])
|
||||
file_mapping = SelectField(_l('File mapping'), [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')})
|
||||
@@ -837,6 +837,8 @@ class processor_text_json_diff_form(commonSettingsForm):
|
||||
conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here
|
||||
use_page_title_in_list = TernaryNoneBooleanField(_l('Use page <title> in list'), default=None)
|
||||
|
||||
history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={"style": "width: 5em;"}, validators=[validators.Optional(), validators.NumberRange(min=2)])
|
||||
|
||||
def extra_tab_content(self):
|
||||
return None
|
||||
|
||||
@@ -1034,6 +1036,8 @@ class globalSettingsApplicationForm(commonSettingsForm):
|
||||
render_kw={"style": "width: 5em;"},
|
||||
validators=[validators.NumberRange(min=0,
|
||||
message=_l("Should contain zero or more attempts"))])
|
||||
|
||||
history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={"style": "width: 5em;"}, validators=[validators.Optional(), validators.NumberRange(min=2)])
|
||||
ui = FormField(globalSettingsApplicationUIForm)
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,13 @@ def render(template_str, **args: t.Any) -> str:
|
||||
return output[:JINJA2_MAX_RETURN_PAYLOAD_SIZE]
|
||||
|
||||
def render_fully_escaped(content):
|
||||
env = jinja2.sandbox.ImmutableSandboxedEnvironment(autoescape=True)
|
||||
template = env.from_string("{{ some_html|e }}")
|
||||
return template.render(some_html=content)
|
||||
"""
|
||||
Escape HTML content safely.
|
||||
|
||||
MEMORY LEAK FIX: Use markupsafe.escape() directly instead of creating
|
||||
Jinja2 environments (was causing 1M+ compilations per page load).
|
||||
Simpler, faster, and no concerns about environment state.
|
||||
"""
|
||||
from markupsafe import escape
|
||||
return str(escape(content))
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ class model(dict):
|
||||
'filter_failure_notification_threshold_attempts': _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT,
|
||||
'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum
|
||||
'global_subtractive_selectors': [],
|
||||
'history_snapshot_max_length': None,
|
||||
'ignore_whitespace': True,
|
||||
'ignore_status_codes': False, #@todo implement, as ternary.
|
||||
'ssim_threshold': '0.96', # Default SSIM threshold for screenshot comparison
|
||||
|
||||
@@ -5,6 +5,11 @@ from changedetectionio.model import watch_base
|
||||
class model(watch_base):
|
||||
|
||||
def __init__(self, *arg, **kw):
|
||||
# Store datastore reference (optional for Tags, but good for consistency)
|
||||
self.__datastore = kw.get('__datastore')
|
||||
if kw.get('__datastore'):
|
||||
del kw['__datastore']
|
||||
|
||||
super(model, self).__init__(*arg, **kw)
|
||||
|
||||
self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import gc
|
||||
from copy import copy
|
||||
|
||||
from blinker import signal
|
||||
from changedetectionio.validate_url import is_safe_valid_url
|
||||
|
||||
@@ -13,7 +16,7 @@ from .. import jinja2_custom as safe_jinja
|
||||
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
|
||||
|
||||
FAVICON_RESAVE_THRESHOLD_SECONDS=86400
|
||||
BROTLI_COMPRESS_SIZE_THRESHOLD = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
|
||||
BROTLI_COMPRESS_SIZE_THRESHOLD = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024*20))
|
||||
|
||||
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
|
||||
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
|
||||
@@ -109,8 +112,15 @@ class model(watch_base):
|
||||
self.__datastore_path = kw.get('datastore_path')
|
||||
if kw.get('datastore_path'):
|
||||
del kw['datastore_path']
|
||||
|
||||
|
||||
self.__datastore = kw.get('__datastore')
|
||||
if not self.__datastore:
|
||||
raise ValueError("Watch object requires '__datastore' reference - cannot access global settings without it")
|
||||
if kw.get('__datastore'):
|
||||
del kw['__datastore']
|
||||
|
||||
super(model, self).__init__(*arg, **kw)
|
||||
|
||||
if kw.get('default'):
|
||||
self.update(kw['default'])
|
||||
del kw['default']
|
||||
@@ -121,6 +131,9 @@ class model(watch_base):
|
||||
# Be sure the cached timestamp is ready
|
||||
bump = self.history
|
||||
|
||||
# Note: __deepcopy__, __getstate__, and __setstate__ are inherited from watch_base
|
||||
# This prevents memory leaks by sharing __datastore reference instead of copying it
|
||||
|
||||
@property
|
||||
def viewed(self):
|
||||
# Don't return viewed when last_viewed is 0 and newest_key is 0
|
||||
@@ -423,16 +436,49 @@ class model(watch_base):
|
||||
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
return f.read()
|
||||
|
||||
def _write_atomic(self, dest, data):
|
||||
def _write_atomic(self, dest, data, mode='wb'):
|
||||
"""Write data atomically to dest using a temp file"""
|
||||
if not os.path.exists(dest):
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile('wb', delete=False, dir=self.watch_data_dir) as tmp:
|
||||
tmp.write(data)
|
||||
tmp.flush()
|
||||
os.fsync(tmp.fileno())
|
||||
tmp_path = tmp.name
|
||||
os.replace(tmp_path, dest)
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode, delete=False, dir=self.watch_data_dir) as tmp:
|
||||
tmp.write(data)
|
||||
tmp.flush()
|
||||
os.fsync(tmp.fileno())
|
||||
tmp_path = tmp.name
|
||||
os.replace(tmp_path, dest)
|
||||
|
||||
def history_trim(self, newest_n_items):
|
||||
from pathlib import Path
|
||||
|
||||
# Sort by timestamp (key)
|
||||
sorted_items = sorted(self.history.items(), key=lambda x: int(x[0]))
|
||||
|
||||
keep_part = dict(sorted_items[-newest_n_items:])
|
||||
delete_part = dict(sorted_items[:-newest_n_items])
|
||||
logger.info( f"[{self.get('uuid')}] Trimming history to most recent {newest_n_items} items, keeping {len(keep_part)} items deleting {len(delete_part)} items.")
|
||||
|
||||
if delete_part:
|
||||
for item in delete_part.items():
|
||||
try:
|
||||
Path(item[1]).unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
logger.critical(f"{str(e)}")
|
||||
finally:
|
||||
logger.debug(f"[{self.get('uuid')}] Deleted {item[1]} history snapshot")
|
||||
try:
|
||||
dest = os.path.join(self.watch_data_dir, self.history_index_filename)
|
||||
output = "\r\n".join(
|
||||
f"{k},{Path(v).name}"
|
||||
for k, v in keep_part.items()
|
||||
)+"\r\n"
|
||||
self._write_atomic(dest=dest, data=output, mode='w')
|
||||
except Exception as e:
|
||||
logger.critical(f"{str(e)}")
|
||||
finally:
|
||||
logger.debug(f"[{self.get('uuid')}] Updated history index {dest}")
|
||||
|
||||
# reimport
|
||||
bump = self.history
|
||||
gc.collect()
|
||||
|
||||
# Save some text file to the appropriate path and bump the history
|
||||
# result_obj from fetch_site_status.run()
|
||||
@@ -441,7 +487,6 @@ class model(watch_base):
|
||||
logger.trace(f"{self.get('uuid')} - Updating {self.history_index_filename} with timestamp {timestamp}")
|
||||
|
||||
self.ensure_data_dir_exists()
|
||||
|
||||
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
|
||||
|
||||
# Binary data - detect file type and save without compression
|
||||
@@ -501,6 +546,15 @@ class model(watch_base):
|
||||
self.__newest_history_key = timestamp
|
||||
self.__history_n += 1
|
||||
|
||||
|
||||
maxlen = (
|
||||
self.get('history_snapshot_max_length')
|
||||
or (self.__datastore and self.__datastore['settings']['application'].get('history_snapshot_max_length'))
|
||||
)
|
||||
|
||||
if maxlen and self.__history_n and self.__history_n > maxlen:
|
||||
self.history_trim(newest_n_items=maxlen)
|
||||
|
||||
# @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status
|
||||
return snapshot_fname
|
||||
|
||||
@@ -613,6 +667,11 @@ class model(watch_base):
|
||||
try:
|
||||
with open(fname, 'wb') as f:
|
||||
f.write(decoded)
|
||||
|
||||
# Invalidate favicon filename cache
|
||||
if hasattr(self, '_favicon_filename_cache'):
|
||||
delattr(self, '_favicon_filename_cache')
|
||||
|
||||
# A signal that could trigger the socket server to update the browser also
|
||||
watch_check_update = signal('watch_favicon_bump')
|
||||
if watch_check_update:
|
||||
@@ -629,20 +688,32 @@ class model(watch_base):
|
||||
Find any favicon.* file in the current working directory
|
||||
and return the contents of the newest one.
|
||||
|
||||
MEMORY LEAK FIX: Cache the result to avoid repeated glob.glob() operations.
|
||||
glob.glob() causes millions of fnmatch allocations when called for every watch on page load.
|
||||
|
||||
Returns:
|
||||
bytes: Contents of the newest favicon file, or None if not found.
|
||||
str: Basename of the newest favicon file, or None if not found.
|
||||
"""
|
||||
# Check cache first (prevents 26M+ allocations from repeated glob operations)
|
||||
cache_key = '_favicon_filename_cache'
|
||||
if hasattr(self, cache_key):
|
||||
return getattr(self, cache_key)
|
||||
|
||||
import glob
|
||||
|
||||
# Search for all favicon.* files
|
||||
files = glob.glob(os.path.join(self.watch_data_dir, "favicon.*"))
|
||||
|
||||
if not files:
|
||||
return None
|
||||
result = None
|
||||
else:
|
||||
# Find the newest by modification time
|
||||
newest_file = max(files, key=os.path.getmtime)
|
||||
result = os.path.basename(newest_file)
|
||||
|
||||
# Find the newest by modification time
|
||||
newest_file = max(files, key=os.path.getmtime)
|
||||
return os.path.basename(newest_file)
|
||||
# Cache the result
|
||||
setattr(self, cache_key, result)
|
||||
return result
|
||||
|
||||
def get_screenshot_as_thumbnail(self, max_age=3200):
|
||||
"""Return path to a square thumbnail of the most recent screenshot.
|
||||
|
||||
@@ -32,6 +32,7 @@ class watch_base(dict):
|
||||
'filter_text_replaced': True,
|
||||
'follow_price_changes': True,
|
||||
'has_ldjson_price_data': None,
|
||||
'history_snapshot_max_length': None,
|
||||
'headers': {}, # Extra headers to send
|
||||
'ignore_text': [], # List of text to ignore when calculating the comparison checksum
|
||||
'ignore_status_codes': None,
|
||||
@@ -139,4 +140,100 @@ class watch_base(dict):
|
||||
super(watch_base, self).__init__(*arg, **kw)
|
||||
|
||||
if self.get('default'):
|
||||
del self['default']
|
||||
del self['default']
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
"""
|
||||
Custom deepcopy for all watch_base subclasses (Watch, Tag, etc.).
|
||||
|
||||
CRITICAL FIX: Prevents copying large reference objects like __datastore
|
||||
which would cause exponential memory growth when Watch objects are deepcopied.
|
||||
|
||||
This is called by:
|
||||
- api/Watch.py:76 (API endpoint)
|
||||
- api/Tags.py:28 (Tags API)
|
||||
- processors/base.py:26 (EVERY processor run)
|
||||
- store/__init__.py:544 (clone watch)
|
||||
- And other locations
|
||||
"""
|
||||
from copy import deepcopy
|
||||
|
||||
# Create new instance without calling __init__
|
||||
cls = self.__class__
|
||||
new_obj = cls.__new__(cls)
|
||||
memo[id(self)] = new_obj
|
||||
|
||||
# Copy the dict data (all the settings)
|
||||
for key, value in self.items():
|
||||
new_obj[key] = deepcopy(value, memo)
|
||||
|
||||
# Copy instance attributes dynamically
|
||||
# This handles Watch-specific attrs (like __datastore) and any future subclass attrs
|
||||
for attr_name in dir(self):
|
||||
# Skip methods, special attrs, and dict keys
|
||||
if attr_name.startswith('_') and not attr_name.startswith('__'):
|
||||
# This catches _model__datastore, _model__history_n, etc.
|
||||
try:
|
||||
attr_value = getattr(self, attr_name)
|
||||
|
||||
# Special handling: Share references to large objects instead of copying
|
||||
# Examples: __datastore, __app_reference, __global_settings, etc.
|
||||
if attr_name.endswith('__datastore') or attr_name.endswith('__app'):
|
||||
# Share the reference (don't copy!) to prevent memory leaks
|
||||
setattr(new_obj, attr_name, attr_value)
|
||||
# Skip cache attributes - let them regenerate on demand
|
||||
elif 'cache' in attr_name.lower():
|
||||
pass # Don't copy caches
|
||||
# Copy regular instance attributes
|
||||
elif not callable(attr_value):
|
||||
setattr(new_obj, attr_name, attr_value)
|
||||
except AttributeError:
|
||||
pass # Attribute doesn't exist in this instance
|
||||
|
||||
return new_obj
|
||||
|
||||
def __getstate__(self):
|
||||
"""
|
||||
Custom pickle serialization for all watch_base subclasses.
|
||||
|
||||
Excludes large reference objects (like __datastore) from serialization.
|
||||
"""
|
||||
# Get the dict data
|
||||
state = dict(self)
|
||||
|
||||
# Collect instance attributes (excluding methods and large references)
|
||||
instance_attrs = {}
|
||||
for attr_name in dir(self):
|
||||
if attr_name.startswith('_') and not attr_name.startswith('__'):
|
||||
try:
|
||||
attr_value = getattr(self, attr_name)
|
||||
# Exclude large reference objects and caches from serialization
|
||||
if not (attr_name.endswith('__datastore') or
|
||||
attr_name.endswith('__app') or
|
||||
'cache' in attr_name.lower() or
|
||||
callable(attr_value)):
|
||||
instance_attrs[attr_name] = attr_value
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if instance_attrs:
|
||||
state['__instance_metadata__'] = instance_attrs
|
||||
|
||||
return state
|
||||
|
||||
def __setstate__(self, state):
|
||||
"""
|
||||
Custom pickle deserialization for all watch_base subclasses.
|
||||
|
||||
WARNING: Large reference objects (like __datastore) are NOT restored!
|
||||
Caller must restore these references after unpickling if needed.
|
||||
"""
|
||||
# Extract metadata
|
||||
metadata = state.pop('__instance_metadata__', {})
|
||||
|
||||
# Restore dict data
|
||||
self.update(state)
|
||||
|
||||
# Restore instance attributes
|
||||
for attr_name, attr_value in metadata.items():
|
||||
setattr(self, attr_name, attr_value)
|
||||
@@ -105,6 +105,30 @@ class ChangeDetectionSpec:
|
||||
"""
|
||||
pass
|
||||
|
||||
@hookspec
|
||||
def register_processor(self):
|
||||
"""Register an external processor plugin.
|
||||
|
||||
External packages can implement this hook to register custom processors
|
||||
that will be discovered alongside built-in processors.
|
||||
|
||||
Returns:
|
||||
dict or None: Dictionary with processor information:
|
||||
{
|
||||
'processor_name': str, # Machine name (e.g., 'osint_recon')
|
||||
'processor_module': module, # Module containing processor.py
|
||||
'processor_class': class, # The perform_site_check class
|
||||
'metadata': { # Optional metadata
|
||||
'name': str, # Display name
|
||||
'description': str, # Description
|
||||
'processor_weight': int,# Sort weight (lower = higher priority)
|
||||
'list_badge_text': str, # Badge text for UI
|
||||
}
|
||||
}
|
||||
Return None if this plugin doesn't provide a processor
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# Set up Plugin Manager
|
||||
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
|
||||
|
||||
@@ -17,9 +17,11 @@ def find_sub_packages(package_name):
|
||||
return [name for _, name, is_pkg in pkgutil.iter_modules(package.__path__) if is_pkg]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def find_processors():
|
||||
"""
|
||||
Find all subclasses of DifferenceDetectionProcessor in the specified package.
|
||||
Results are cached to avoid repeated discovery.
|
||||
|
||||
:param package_name: The name of the package to scan for processor modules.
|
||||
:return: A list of (module, class) tuples.
|
||||
@@ -46,6 +48,23 @@ def find_processors():
|
||||
except (ModuleNotFoundError, ImportError) as e:
|
||||
logger.warning(f"Failed to import module {module_name}: {e} (find_processors())")
|
||||
|
||||
# Discover plugin processors via pluggy
|
||||
try:
|
||||
from changedetectionio.pluggy_interface import plugin_manager
|
||||
plugin_results = plugin_manager.hook.register_processor()
|
||||
|
||||
for result in plugin_results:
|
||||
if result and isinstance(result, dict):
|
||||
processor_module = result.get('processor_module')
|
||||
processor_name = result.get('processor_name')
|
||||
|
||||
if processor_module and processor_name:
|
||||
processors.append((processor_module, processor_name))
|
||||
plugin_path = getattr(processor_module, '__file__', 'unknown location')
|
||||
logger.info(f"Registered plugin processor: {processor_name} from {plugin_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading plugin processors: {e}")
|
||||
|
||||
return processors
|
||||
|
||||
|
||||
@@ -97,54 +116,137 @@ def find_processor_module(processor_name):
|
||||
return None
|
||||
|
||||
|
||||
def get_processor_module(processor_name):
|
||||
"""
|
||||
Get the actual processor module (with perform_site_check class) by name.
|
||||
Works for both built-in and plugin processors.
|
||||
|
||||
Args:
|
||||
processor_name: Processor machine name (e.g., 'text_json_diff', 'osint_recon')
|
||||
|
||||
Returns:
|
||||
module: The processor module containing perform_site_check, or None if not found
|
||||
"""
|
||||
processor_classes = find_processors()
|
||||
processor_tuple = next((tpl for tpl in processor_classes if tpl[1] == processor_name), None)
|
||||
|
||||
if processor_tuple:
|
||||
# Return the actual processor module (first element of tuple)
|
||||
return processor_tuple[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_processor_submodule(processor_name, submodule_name):
|
||||
"""
|
||||
Get an optional submodule from a processor (e.g., 'difference', 'extract', 'preview').
|
||||
Works for both built-in and plugin processors.
|
||||
|
||||
Args:
|
||||
processor_name: Processor machine name (e.g., 'text_json_diff', 'osint_recon')
|
||||
submodule_name: Name of the submodule (e.g., 'difference', 'extract', 'preview')
|
||||
|
||||
Returns:
|
||||
module: The submodule if it exists, or None if not found
|
||||
"""
|
||||
processor_classes = find_processors()
|
||||
processor_tuple = next((tpl for tpl in processor_classes if tpl[1] == processor_name), None)
|
||||
|
||||
if not processor_tuple:
|
||||
return None
|
||||
|
||||
processor_module = processor_tuple[0]
|
||||
parent_module = get_parent_module(processor_module)
|
||||
|
||||
if not parent_module:
|
||||
return None
|
||||
|
||||
# Try to import the submodule
|
||||
try:
|
||||
# For built-in processors: changedetectionio.processors.text_json_diff.difference
|
||||
# For plugin processors: changedetectionio_osint.difference
|
||||
parent_module_name = parent_module.__name__
|
||||
submodule_full_name = f"{parent_module_name}.{submodule_name}"
|
||||
return importlib.import_module(submodule_full_name)
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_plugin_processor_metadata():
|
||||
"""Get metadata from plugin processors."""
|
||||
metadata = {}
|
||||
try:
|
||||
from changedetectionio.pluggy_interface import plugin_manager
|
||||
plugin_results = plugin_manager.hook.register_processor()
|
||||
|
||||
for result in plugin_results:
|
||||
if result and isinstance(result, dict):
|
||||
processor_name = result.get('processor_name')
|
||||
meta = result.get('metadata', {})
|
||||
if processor_name:
|
||||
metadata[processor_name] = meta
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting plugin processor metadata: {e}")
|
||||
return metadata
|
||||
|
||||
|
||||
def available_processors():
|
||||
"""
|
||||
Get a list of processors by name and description for the UI elements.
|
||||
Can be filtered via ALLOWED_PROCESSORS environment variable (comma-separated list).
|
||||
Can be filtered via DISABLED_PROCESSORS environment variable (comma-separated list).
|
||||
:return: A list :)
|
||||
"""
|
||||
|
||||
processor_classes = find_processors()
|
||||
|
||||
# Check if ALLOWED_PROCESSORS env var is set
|
||||
# For now we disable it, need to make a deploy with lots of new code and this will be an overload
|
||||
allowed_processors_env = os.getenv('ALLOWED_PROCESSORS', 'text_json_diff, restock_diff').strip()
|
||||
allowed_processors = None
|
||||
if allowed_processors_env:
|
||||
# Check if DISABLED_PROCESSORS env var is set
|
||||
disabled_processors_env = os.getenv('DISABLED_PROCESSORS', 'image_ssim_diff').strip()
|
||||
disabled_processors = []
|
||||
if disabled_processors_env:
|
||||
# Parse comma-separated list and strip whitespace
|
||||
allowed_processors = [p.strip() for p in allowed_processors_env.split(',') if p.strip()]
|
||||
logger.info(f"ALLOWED_PROCESSORS set, filtering to: {allowed_processors}")
|
||||
disabled_processors = [p.strip() for p in disabled_processors_env.split(',') if p.strip()]
|
||||
logger.info(f"DISABLED_PROCESSORS set, disabling: {disabled_processors}")
|
||||
|
||||
available = []
|
||||
plugin_metadata = get_plugin_processor_metadata()
|
||||
|
||||
for module, sub_package_name in processor_classes:
|
||||
# Filter by allowed processors if set
|
||||
if allowed_processors and sub_package_name not in allowed_processors:
|
||||
logger.debug(f"Skipping processor '{sub_package_name}' (not in ALLOWED_PROCESSORS)")
|
||||
# Skip disabled processors
|
||||
if sub_package_name in disabled_processors:
|
||||
logger.debug(f"Skipping processor '{sub_package_name}' (in DISABLED_PROCESSORS)")
|
||||
continue
|
||||
|
||||
# Try to get the 'name' attribute from the processor module first
|
||||
if hasattr(module, 'name'):
|
||||
description = gettext(module.name)
|
||||
# Check if this is a plugin processor
|
||||
if sub_package_name in plugin_metadata:
|
||||
meta = plugin_metadata[sub_package_name]
|
||||
description = gettext(meta.get('name', sub_package_name))
|
||||
# Plugin processors start from weight 10 to separate them from built-in processors
|
||||
weight = 100 + meta.get('processor_weight', 0)
|
||||
else:
|
||||
# Fall back to processor_description from parent module's __init__.py
|
||||
parent_module = get_parent_module(module)
|
||||
if parent_module and hasattr(parent_module, 'processor_description'):
|
||||
description = gettext(parent_module.processor_description)
|
||||
# Try to get the 'name' attribute from the processor module first
|
||||
if hasattr(module, 'name'):
|
||||
description = gettext(module.name)
|
||||
else:
|
||||
# Final fallback to a readable name
|
||||
description = sub_package_name.replace('_', ' ').title()
|
||||
# Fall back to processor_description from parent module's __init__.py
|
||||
parent_module = get_parent_module(module)
|
||||
if parent_module and hasattr(parent_module, 'processor_description'):
|
||||
description = gettext(parent_module.processor_description)
|
||||
else:
|
||||
# Final fallback to a readable name
|
||||
description = sub_package_name.replace('_', ' ').title()
|
||||
|
||||
# Get weight for sorting (lower weight = higher in list)
|
||||
weight = 0 # Default weight for processors without explicit weight
|
||||
# Get weight for sorting (lower weight = higher in list)
|
||||
weight = 0 # Default weight for processors without explicit weight
|
||||
|
||||
# Check processor module itself first
|
||||
if hasattr(module, 'processor_weight'):
|
||||
weight = module.processor_weight
|
||||
else:
|
||||
# Fall back to parent module (package __init__.py)
|
||||
parent_module = get_parent_module(module)
|
||||
if parent_module and hasattr(parent_module, 'processor_weight'):
|
||||
weight = parent_module.processor_weight
|
||||
# Check processor module itself first
|
||||
if hasattr(module, 'processor_weight'):
|
||||
weight = module.processor_weight
|
||||
else:
|
||||
# Fall back to parent module (package __init__.py)
|
||||
parent_module = get_parent_module(module)
|
||||
if parent_module and hasattr(parent_module, 'processor_weight'):
|
||||
weight = parent_module.processor_weight
|
||||
|
||||
available.append((sub_package_name, description, weight))
|
||||
|
||||
@@ -155,6 +257,20 @@ def available_processors():
|
||||
return [(name, desc) for name, desc, weight in available]
|
||||
|
||||
|
||||
def get_default_processor():
|
||||
"""
|
||||
Get the default processor to use when none is specified.
|
||||
Returns the first available processor based on weight (lowest weight = highest priority).
|
||||
This ensures forms auto-select a valid processor even when DISABLED_PROCESSORS filters the list.
|
||||
|
||||
:return: The processor name string (e.g., 'text_json_diff')
|
||||
"""
|
||||
available = available_processors()
|
||||
if available:
|
||||
return available[0][0] # Return the processor name from first tuple
|
||||
return 'text_json_diff' # Fallback if somehow no processors are available
|
||||
|
||||
|
||||
def get_processor_badge_texts():
|
||||
"""
|
||||
Get a dictionary mapping processor names to their list_badge_text values.
|
||||
@@ -279,3 +395,76 @@ def get_processor_badge_css():
|
||||
|
||||
return '\n\n'.join(css_rules)
|
||||
|
||||
|
||||
def save_processor_config(datastore, watch_uuid, config_data):
|
||||
"""
|
||||
Save processor-specific configuration to JSON file.
|
||||
|
||||
This is a shared helper function used by both the UI edit form and API endpoints
|
||||
to consistently handle processor configuration storage.
|
||||
|
||||
Args:
|
||||
datastore: The application datastore instance
|
||||
watch_uuid: UUID of the watch
|
||||
config_data: Dictionary of configuration data to save (with processor_config_* prefix removed)
|
||||
|
||||
Returns:
|
||||
bool: True if saved successfully, False otherwise
|
||||
"""
|
||||
if not config_data:
|
||||
return True
|
||||
|
||||
try:
|
||||
from changedetectionio.processors.base import difference_detection_processor
|
||||
|
||||
# Get processor name from watch
|
||||
watch = datastore.data['watching'].get(watch_uuid)
|
||||
if not watch:
|
||||
logger.error(f"Cannot save processor config: watch {watch_uuid} not found")
|
||||
return False
|
||||
|
||||
processor_name = watch.get('processor', 'text_json_diff')
|
||||
|
||||
# Create a processor instance to access config methods
|
||||
processor_instance = difference_detection_processor(datastore, watch_uuid)
|
||||
|
||||
# Use processor name as filename so each processor keeps its own config
|
||||
config_filename = f'{processor_name}.json'
|
||||
processor_instance.update_extra_watch_config(config_filename, config_data)
|
||||
|
||||
logger.debug(f"Saved processor config to {config_filename}: {config_data}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save processor config: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def extract_processor_config_from_form_data(form_data):
|
||||
"""
|
||||
Extract processor_config_* fields from form data and return separate dicts.
|
||||
|
||||
This is a shared helper function used by both the UI edit form and API endpoints
|
||||
to consistently handle processor configuration extraction.
|
||||
|
||||
IMPORTANT: This function modifies form_data in-place by removing processor_config_* fields.
|
||||
|
||||
Args:
|
||||
form_data: Dictionary of form data (will be modified in-place)
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of processor config data (with processor_config_* prefix removed)
|
||||
"""
|
||||
processor_config_data = {}
|
||||
|
||||
# Use list() to create a copy of keys since we're modifying the dict
|
||||
for field_name in list(form_data.keys()):
|
||||
if field_name.startswith('processor_config_'):
|
||||
config_key = field_name.replace('processor_config_', '')
|
||||
# Save all values (including empty strings) to allow explicit clearing of settings
|
||||
processor_config_data[config_key] = form_data[field_name]
|
||||
# Remove from form_data to prevent it from reaching datastore
|
||||
del form_data[field_name]
|
||||
|
||||
return processor_config_data
|
||||
|
||||
|
||||
@@ -23,7 +23,14 @@ class difference_detection_processor():
|
||||
def __init__(self, datastore, watch_uuid):
|
||||
self.datastore = datastore
|
||||
self.watch_uuid = watch_uuid
|
||||
|
||||
# Create a stable snapshot of the watch for processing
|
||||
# Why deepcopy?
|
||||
# 1. Prevents "dict changed during iteration" errors if watch is modified during processing
|
||||
# 2. Preserves Watch object with properties (.link, .is_pdf, etc.) - can't use dict()
|
||||
# 3. Safe now: Watch.__deepcopy__() shares datastore ref (no memory leak) but copies dict data
|
||||
self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid))
|
||||
|
||||
# Generic fetcher that should be extended (requests, playwright etc)
|
||||
self.fetcher = Fetcher()
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@ processor_description = "Visual/Screenshot change detection (Fast)"
|
||||
processor_name = "image_ssim_diff"
|
||||
processor_weight = 2 # Lower weight = appears at top, heavier weight = appears lower (bottom)
|
||||
|
||||
# Processor capabilities
|
||||
supports_visual_selector = True
|
||||
supports_browser_steps = True
|
||||
supports_text_filters_and_triggers = False
|
||||
supports_text_filters_and_triggers_elements = False
|
||||
supports_request_type = True
|
||||
|
||||
PROCESSOR_CONFIG_NAME = f"{Path(__file__).parent.name}.json"
|
||||
|
||||
# Subprocess timeout settings
|
||||
|
||||
@@ -4,6 +4,13 @@ from changedetectionio.model.Watch import model as BaseWatch
|
||||
from typing import Union
|
||||
import re
|
||||
|
||||
# Processor capabilities
|
||||
supports_visual_selector = True
|
||||
supports_browser_steps = True
|
||||
supports_text_filters_and_triggers = True
|
||||
supports_text_filters_and_triggers_elements = True
|
||||
supports_request_type = True
|
||||
|
||||
class Restock(dict):
|
||||
|
||||
def parse_currency(self, raw_value: str) -> Union[float, None]:
|
||||
|
||||
@@ -193,18 +193,17 @@ class perform_site_check(difference_detection_processor):
|
||||
|
||||
|
||||
itemprop_availability = {}
|
||||
multiple_prices_found = False
|
||||
|
||||
# Try built-in extraction first, this will scan metadata in the HTML
|
||||
try:
|
||||
itemprop_availability = get_itemprop_availability(self.fetcher.content)
|
||||
except MoreThanOnePriceFound as e:
|
||||
# Add the real data
|
||||
raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
|
||||
url=watch.get('url'),
|
||||
status_code=self.fetcher.get_last_status_code(),
|
||||
screenshot=self.fetcher.screenshot,
|
||||
xpath_data=self.fetcher.xpath_data
|
||||
)
|
||||
# Don't raise immediately - let plugins try to handle this case
|
||||
# Plugins might be able to determine which price is correct
|
||||
logger.warning(f"Built-in detection found multiple prices on {watch.get('url')}, will try plugin override")
|
||||
multiple_prices_found = True
|
||||
itemprop_availability = {}
|
||||
|
||||
# If built-in extraction didn't get both price AND availability, try plugin override
|
||||
# Only check plugin if this watch is using a fetcher that might provide better data
|
||||
@@ -216,9 +215,21 @@ class perform_site_check(difference_detection_processor):
|
||||
from changedetectionio.pluggy_interface import get_itemprop_availability_from_plugin
|
||||
fetcher_name = watch.get('fetch_backend', 'html_requests')
|
||||
|
||||
# Only try plugin override if not using system default (which might be anything)
|
||||
if fetcher_name and fetcher_name != 'system':
|
||||
logger.debug("Calling extra plugins for getting item price/availability")
|
||||
# Resolve 'system' to the actual fetcher being used
|
||||
# This allows plugins to work even when watch uses "system settings default"
|
||||
if fetcher_name == 'system':
|
||||
# Get the actual fetcher that was used (from self.fetcher)
|
||||
# Fetcher class name gives us the actual backend (e.g., 'html_requests', 'html_webdriver')
|
||||
actual_fetcher = type(self.fetcher).__name__
|
||||
if 'html_requests' in actual_fetcher.lower():
|
||||
fetcher_name = 'html_requests'
|
||||
elif 'webdriver' in actual_fetcher.lower() or 'playwright' in actual_fetcher.lower():
|
||||
fetcher_name = 'html_webdriver'
|
||||
logger.debug(f"Resolved 'system' fetcher to actual fetcher: {fetcher_name}")
|
||||
|
||||
# Try plugin override - plugins can decide if they support this fetcher
|
||||
if fetcher_name:
|
||||
logger.debug(f"Calling extra plugins for getting item price/availability (fetcher: {fetcher_name})")
|
||||
plugin_availability = get_itemprop_availability_from_plugin(self.fetcher.content, fetcher_name, self.fetcher, watch.link)
|
||||
|
||||
if plugin_availability:
|
||||
@@ -233,6 +244,16 @@ class perform_site_check(difference_detection_processor):
|
||||
if not plugin_availability:
|
||||
logger.debug("No item price/availability from plugins")
|
||||
|
||||
# If we had multiple prices and plugins also failed, NOW raise the exception
|
||||
if multiple_prices_found and not itemprop_availability.get('price'):
|
||||
raise ProcessorException(
|
||||
message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
|
||||
url=watch.get('url'),
|
||||
status_code=self.fetcher.get_last_status_code(),
|
||||
screenshot=self.fetcher.screenshot,
|
||||
xpath_data=self.fetcher.xpath_data
|
||||
)
|
||||
|
||||
# Something valid in get_itemprop_availability() by scraping metadata ?
|
||||
if itemprop_availability.get('price') or itemprop_availability.get('availability'):
|
||||
# Store for other usage
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Processor capabilities
|
||||
supports_visual_selector = True
|
||||
supports_browser_steps = True
|
||||
supports_text_filters_and_triggers = True
|
||||
supports_text_filters_and_triggers_elements = True
|
||||
supports_request_type = True
|
||||
|
||||
|
||||
|
||||
def _task(watch, update_handler):
|
||||
@@ -58,7 +64,7 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
|
||||
# Only update vars that came in via the AJAX post
|
||||
p = {k: v for k, v in form.data.items() if k in form_data.keys()}
|
||||
tmp_watch.update(p)
|
||||
blank_watch_no_filters = watch_model()
|
||||
blank_watch_no_filters = watch_model(datastore_path=datastore.datastore_path, __datastore=datastore.data)
|
||||
blank_watch_no_filters['url'] = tmp_watch.get('url')
|
||||
|
||||
latest_filename = next(reversed(tmp_watch.history))
|
||||
|
||||
@@ -67,7 +67,7 @@ echo "-------------------- Running rest of tests in parallel -------------------
|
||||
# REMOVE_REQUESTS_OLD_SCREENSHOTS disabled so that we can write a screenshot and send it in test_notifications.py without a real browser
|
||||
FETCH_WORKERS=2 REMOVE_REQUESTS_OLD_SCREENSHOTS=false \
|
||||
pytest tests/test_*.py \
|
||||
-n 18 \
|
||||
-n 8 \
|
||||
--dist=load \
|
||||
-vvv \
|
||||
-s \
|
||||
|
||||
@@ -9,18 +9,14 @@ from flask import (
|
||||
)
|
||||
from flask_babel import gettext
|
||||
|
||||
from ..blueprint.rss import RSS_CONTENT_FORMAT_DEFAULT
|
||||
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
|
||||
from ..model import App, Watch, USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
|
||||
from copy import deepcopy, copy
|
||||
from ..model import App, Watch
|
||||
from copy import deepcopy
|
||||
from os import path, unlink
|
||||
from threading import Lock
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid as uuid_builder
|
||||
from loguru import logger
|
||||
@@ -35,7 +31,6 @@ except ImportError:
|
||||
HAS_ORJSON = False
|
||||
|
||||
from ..processors import get_custom_watch_obj_for_processor
|
||||
from ..processors.restock_diff import Restock
|
||||
|
||||
# Import the base class and helpers
|
||||
from .file_saving_datastore import FileSavingDataStore, load_all_watches, save_watch_atomic, save_json_atomic
|
||||
@@ -137,6 +132,19 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
)
|
||||
logger.info(f"Tag: {uuid} {tag['title']}")
|
||||
|
||||
def _rehydrate_watches(self):
|
||||
"""Rehydrate watch entities from stored data (converts dicts to Watch objects)."""
|
||||
watch_count = len(self.__data.get('watching', {}))
|
||||
if watch_count == 0:
|
||||
return
|
||||
|
||||
logger.info(f"Rehydrating {watch_count} watches...")
|
||||
watching_rehydrated = {}
|
||||
for uuid, watch_dict in self.__data.get('watching', {}).items():
|
||||
watching_rehydrated[uuid] = self.rehydrate_entity(uuid, watch_dict)
|
||||
self.__data['watching'] = watching_rehydrated
|
||||
logger.success(f"Rehydrated {watch_count} watches into Watch objects")
|
||||
|
||||
|
||||
def _load_state(self):
|
||||
"""
|
||||
@@ -174,7 +182,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
self.json_store_path = os.path.join(self.datastore_path, "changedetection.json")
|
||||
|
||||
# Base definition for all watchers (deepcopy part of #569)
|
||||
self.generic_definition = deepcopy(Watch.model(datastore_path=datastore_path, default={}))
|
||||
self.generic_definition = deepcopy(Watch.model(datastore_path=datastore_path, __datastore=self.__data, default={}))
|
||||
|
||||
# Load build SHA if available (Docker deployments)
|
||||
if path.isfile('changedetectionio/source.txt'):
|
||||
@@ -218,23 +226,29 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
logger.critical(f"Legacy datastore detected at {self.datastore_path}/url-watches.json")
|
||||
logger.critical("Migration will be triggered via update_26")
|
||||
|
||||
# Load the legacy datastore to get its schema_version
|
||||
# Load the legacy datastore
|
||||
from .legacy_loader import load_legacy_format
|
||||
legacy_path = os.path.join(self.datastore_path, "url-watches.json")
|
||||
with open(legacy_path) as f:
|
||||
self.__data = json.load(f)
|
||||
legacy_data = load_legacy_format(legacy_path)
|
||||
|
||||
if not self.__data:
|
||||
if not legacy_data:
|
||||
raise Exception("Failed to load legacy datastore from url-watches.json")
|
||||
|
||||
# update_26 will load the legacy data again and migrate to new format
|
||||
# Only run updates AFTER the legacy schema version (e.g., if legacy is at 25, only run 26+)
|
||||
# Store the loaded data
|
||||
self.__data = legacy_data
|
||||
|
||||
# CRITICAL: Rehydrate watches from dicts into Watch objects
|
||||
# This ensures watches have their methods available during migration
|
||||
self._rehydrate_watches()
|
||||
|
||||
# update_26 will save watches to individual files and create changedetection.json
|
||||
# Next startup will load from new format normally
|
||||
self.run_updates()
|
||||
|
||||
|
||||
else:
|
||||
# Fresh install - create new datastore
|
||||
logger.critical(f"No datastore found, creating new datastore at {self.datastore_path}")
|
||||
logger.warning(f"No datastore found, creating new datastore at {self.datastore_path}")
|
||||
|
||||
# Set schema version to latest (no updates needed)
|
||||
updates_available = self.get_updates_available()
|
||||
@@ -308,7 +322,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
if entity.get('processor') != 'text_json_diff':
|
||||
logger.trace(f"Loading Watch object '{watch_class.__module__}.{watch_class.__name__}' for UUID {uuid}")
|
||||
|
||||
entity = watch_class(datastore_path=self.datastore_path, default=entity)
|
||||
entity = watch_class(datastore_path=self.datastore_path, __datastore=self.__data, default=entity)
|
||||
return entity
|
||||
|
||||
# ============================================================================
|
||||
@@ -527,7 +541,11 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
# Clone a watch by UUID
|
||||
def clone(self, uuid):
|
||||
url = self.data['watching'][uuid].get('url')
|
||||
extras = deepcopy(self.data['watching'][uuid])
|
||||
# No need to deepcopy here - add_watch() will deepcopy extras anyway (line 569)
|
||||
# Just pass a dict copy (with lock for thread safety)
|
||||
# NOTE: dict() is shallow copy but safe since add_watch() deepcopies it
|
||||
with self.lock:
|
||||
extras = dict(self.data['watching'][uuid])
|
||||
new_uuid = self.add_watch(url=url, extras=extras)
|
||||
watch = self.data['watching'][new_uuid]
|
||||
return new_uuid
|
||||
@@ -639,7 +657,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
|
||||
# If the processor also has its own Watch implementation
|
||||
watch_class = get_custom_watch_obj_for_processor(apply_extras.get('processor'))
|
||||
new_watch = watch_class(datastore_path=self.datastore_path, url=url)
|
||||
new_watch = watch_class(datastore_path=self.datastore_path, __datastore=self.__data, url=url)
|
||||
|
||||
new_uuid = new_watch.get('uuid')
|
||||
|
||||
@@ -858,10 +876,14 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
# So we use the same model as a Watch
|
||||
with self.lock:
|
||||
from ..model import Tag
|
||||
new_tag = Tag.model(datastore_path=self.datastore_path, default={
|
||||
'title': title.strip(),
|
||||
'date_created': int(time.time())
|
||||
})
|
||||
new_tag = Tag.model(
|
||||
datastore_path=self.datastore_path,
|
||||
__datastore=self.__data,
|
||||
default={
|
||||
'title': title.strip(),
|
||||
'date_created': int(time.time())
|
||||
}
|
||||
)
|
||||
|
||||
new_uuid = new_tag.get('uuid')
|
||||
|
||||
|
||||
41
changedetectionio/tests/plugins/test_processor.py
Normal file
41
changedetectionio/tests/plugins/test_processor.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import time
|
||||
|
||||
from flask import url_for
|
||||
|
||||
from changedetectionio.tests.util import wait_for_all_checks
|
||||
|
||||
|
||||
def test_check_plugin_processor(client, live_server, measure_memory_usage, datastore_path):
|
||||
# requires os-int intelligence plugin installed (first basic one we test with)
|
||||
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'OSINT Reconnaissance' in res.data, "Must have the OSINT plugin installed at test time"
|
||||
assert b'<input checked id="processor-0" name="processor" type="radio" value="text_json_diff">' in res.data, "But the first text_json_diff processor should always be selected by default in quick watch form"
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_views.form_quick_watch_add"),
|
||||
data={"url": 'http://127.0.0.1', "tags": '', 'processor': 'osint_recon'},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b"Watch added" in res.data
|
||||
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'Target: http://127.0.0.1' in res.data
|
||||
assert b'DNSKEY Records' in res.data
|
||||
wait_for_all_checks(client)
|
||||
|
||||
|
||||
# Now change it to something that doesnt exist
|
||||
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
|
||||
live_server.app.config['DATASTORE'].data['watching'][uuid]['processor'] = "now_missing"
|
||||
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"Exception: Processor module" in res.data and b'now_missing' in res.data, f'Should register that the plugin is missing for {uuid}'
|
||||
@@ -182,3 +182,86 @@ def test_check_text_history_view(client, live_server, measure_memory_usage, data
|
||||
assert b'test-one' not in res.data
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_history_trim_global_only(client, live_server, measure_memory_usage, datastore_path):
|
||||
# Add our URL to the import page
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
uuid = None
|
||||
limit = 3
|
||||
|
||||
for i in range(0, 10):
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(f"<html>test {i}</html>")
|
||||
if not uuid:
|
||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
if i ==8:
|
||||
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
|
||||
history_n = len(list(watch.history.keys()))
|
||||
logger.debug(f"History length should be at limit {limit} and it is {history_n}")
|
||||
assert history_n == limit
|
||||
|
||||
if i == 6:
|
||||
res = client.post(
|
||||
url_for("settings.settings_page"),
|
||||
data={"application-history_snapshot_max_length": limit},
|
||||
follow_redirects=True
|
||||
)
|
||||
# It will need to detect one more change to start trimming it, which is really at 'start of 7'
|
||||
assert b'Settings updated' in res.data
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_history_trim_global_override_in_watch(client, live_server, measure_memory_usage, datastore_path):
|
||||
# Add our URL to the import page
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
uuid = None
|
||||
limit = 3
|
||||
res = client.post(
|
||||
url_for("settings.settings_page"),
|
||||
data={"application-history_snapshot_max_length": 10000},
|
||||
follow_redirects=True
|
||||
)
|
||||
# It will need to detect one more change to start trimming it, which is really at 'start of 7'
|
||||
assert b'Settings updated' in res.data
|
||||
|
||||
|
||||
for i in range(0, 10):
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(f"<html>test {i}</html>")
|
||||
if not uuid:
|
||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid="first"),
|
||||
data={"include_filters": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests",
|
||||
"time_between_check_use_default": "y", "history_snapshot_max_length": str(limit)},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b"Updated watch." in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
if i == 8:
|
||||
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
|
||||
history_n = len(list(watch.history.keys()))
|
||||
logger.debug(f"History length should be at limit {limit} and it is {history_n}")
|
||||
assert history_n == limit
|
||||
|
||||
if i == 6:
|
||||
res = client.post(
|
||||
url_for("settings.settings_page"),
|
||||
data={"application-history_snapshot_max_length": limit},
|
||||
follow_redirects=True
|
||||
)
|
||||
# It will need to detect one more change to start trimming it, which is really at 'start of 7'
|
||||
assert b'Settings updated' in res.data
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
@@ -5,15 +5,24 @@
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import pickle
|
||||
from copy import deepcopy
|
||||
|
||||
from changedetectionio.model import Watch
|
||||
from changedetectionio.model import Watch, Tag
|
||||
|
||||
# mostly
|
||||
class TestDiffBuilder(unittest.TestCase):
|
||||
|
||||
def test_watch_get_suggested_from_diff_timestamp(self):
|
||||
import uuid as uuid_builder
|
||||
watch = Watch.model(datastore_path='/tmp', default={})
|
||||
# Create minimal mock datastore for tests
|
||||
mock_datastore = {
|
||||
'settings': {
|
||||
'application': {}
|
||||
},
|
||||
'watching': {}
|
||||
}
|
||||
watch = Watch.model(datastore_path='/tmp', __datastore=mock_datastore, default={})
|
||||
watch.ensure_data_dir_exists()
|
||||
|
||||
|
||||
@@ -49,7 +58,7 @@ class TestDiffBuilder(unittest.TestCase):
|
||||
assert p == "109", "Correct when its the same time"
|
||||
|
||||
# new empty one
|
||||
watch = Watch.model(datastore_path='/tmp', default={})
|
||||
watch = Watch.model(datastore_path='/tmp', __datastore=mock_datastore, default={})
|
||||
p = watch.get_from_version_based_on_last_viewed
|
||||
assert p == None, "None when no history available"
|
||||
|
||||
@@ -61,5 +70,184 @@ class TestDiffBuilder(unittest.TestCase):
|
||||
p = watch.get_from_version_based_on_last_viewed
|
||||
assert p == "100", "Correct with only one history snapshot"
|
||||
|
||||
def test_watch_deepcopy_doesnt_copy_datastore(self):
|
||||
"""
|
||||
CRITICAL: Ensure deepcopy(watch) shares __datastore instead of copying it.
|
||||
|
||||
Without this, deepcopy causes exponential memory growth:
|
||||
- 100 watches × deepcopy each = 10,000 watch objects in memory (100²)
|
||||
- Memory grows from 120MB → 2GB
|
||||
|
||||
This test prevents regressions in the __deepcopy__ implementation.
|
||||
"""
|
||||
# Create mock datastore with multiple watches
|
||||
mock_datastore = {
|
||||
'settings': {'application': {'history_snapshot_max_length': 10}},
|
||||
'watching': {}
|
||||
}
|
||||
|
||||
# Create 3 watches that all reference the same datastore
|
||||
watches = []
|
||||
for i in range(3):
|
||||
watch = Watch.model(
|
||||
__datastore=mock_datastore,
|
||||
datastore_path='/tmp/test',
|
||||
default={'url': f'https://example{i}.com', 'title': f'Watch {i}'}
|
||||
)
|
||||
mock_datastore['watching'][watch['uuid']] = watch
|
||||
watches.append(watch)
|
||||
|
||||
# Test 1: Deepcopy shares datastore reference (doesn't copy it)
|
||||
watch_copy = deepcopy(watches[0])
|
||||
|
||||
self.assertIsNotNone(watch_copy._model__datastore,
|
||||
"__datastore should exist in copied watch")
|
||||
self.assertIs(watch_copy._model__datastore, watches[0]._model__datastore,
|
||||
"__datastore should be SHARED (same object), not copied")
|
||||
self.assertIs(watch_copy._model__datastore, mock_datastore,
|
||||
"__datastore should reference the original datastore")
|
||||
|
||||
# Test 2: Dict data is properly copied (not shared)
|
||||
self.assertEqual(watch_copy['title'], 'Watch 0', "Dict data should be copied")
|
||||
watch_copy['title'] = 'MODIFIED'
|
||||
self.assertNotEqual(watches[0]['title'], 'MODIFIED',
|
||||
"Modifying copy should not affect original")
|
||||
|
||||
# Test 3: Verify no nested datastore copies in watch dict
|
||||
# The dict should only contain watch settings, not the datastore
|
||||
watch_dict = dict(watch_copy)
|
||||
self.assertNotIn('__datastore', watch_dict,
|
||||
"__datastore should not be in dict keys")
|
||||
self.assertNotIn('_model__datastore', watch_dict,
|
||||
"_model__datastore should not be in dict keys")
|
||||
|
||||
# Test 4: Multiple deepcopies don't cause exponential memory growth
|
||||
# If datastore was copied, each copy would contain 3 watches,
|
||||
# and those watches would contain the datastore, etc. (infinite recursion)
|
||||
copies = []
|
||||
for _ in range(5):
|
||||
copies.append(deepcopy(watches[0]))
|
||||
|
||||
# All copies should share the same datastore
|
||||
for copy in copies:
|
||||
self.assertIs(copy._model__datastore, mock_datastore,
|
||||
"All copies should share the original datastore")
|
||||
|
||||
def test_watch_pickle_doesnt_serialize_datastore(self):
|
||||
"""
|
||||
Ensure pickle/unpickle doesn't serialize __datastore.
|
||||
|
||||
This is important for multiprocessing and caching - we don't want
|
||||
to serialize the entire datastore when pickling a watch.
|
||||
"""
|
||||
mock_datastore = {
|
||||
'settings': {'application': {}},
|
||||
'watching': {}
|
||||
}
|
||||
|
||||
watch = Watch.model(
|
||||
__datastore=mock_datastore,
|
||||
datastore_path='/tmp/test',
|
||||
default={'url': 'https://example.com', 'title': 'Test Watch'}
|
||||
)
|
||||
|
||||
# Pickle and unpickle
|
||||
pickled = pickle.dumps(watch)
|
||||
unpickled_watch = pickle.loads(pickled)
|
||||
|
||||
# Test 1: Watch data is preserved
|
||||
self.assertEqual(unpickled_watch['url'], 'https://example.com',
|
||||
"Dict data should be preserved after pickle/unpickle")
|
||||
|
||||
# Test 2: __datastore is NOT serialized (attribute shouldn't exist after unpickle)
|
||||
self.assertFalse(hasattr(unpickled_watch, '_model__datastore'),
|
||||
"__datastore attribute should not exist after unpickle (not serialized)")
|
||||
|
||||
# Test 3: Pickled data shouldn't contain the large datastore object
|
||||
# If datastore was serialized, the pickle size would be much larger
|
||||
pickle_size = len(pickled)
|
||||
# A single watch should be small (< 10KB), not include entire datastore
|
||||
self.assertLess(pickle_size, 10000,
|
||||
f"Pickled watch too large ({pickle_size} bytes) - might include datastore")
|
||||
|
||||
def test_tag_deepcopy_works(self):
|
||||
"""
|
||||
Ensure Tag objects (which also inherit from watch_base) can be deepcopied.
|
||||
|
||||
Tags now have optional __datastore for consistency with Watch objects.
|
||||
"""
|
||||
mock_datastore = {
|
||||
'settings': {'application': {}},
|
||||
'watching': {}
|
||||
}
|
||||
|
||||
# Test 1: Tag without datastore (backward compatibility)
|
||||
tag_without_ds = Tag.model(
|
||||
datastore_path='/tmp/test',
|
||||
default={'title': 'Test Tag', 'overrides_watch': True}
|
||||
)
|
||||
tag_copy1 = deepcopy(tag_without_ds)
|
||||
self.assertEqual(tag_copy1['title'], 'Test Tag', "Tag data should be copied")
|
||||
|
||||
# Test 2: Tag with datastore (new pattern for consistency)
|
||||
tag_with_ds = Tag.model(
|
||||
datastore_path='/tmp/test',
|
||||
__datastore=mock_datastore,
|
||||
default={'title': 'Test Tag With DS', 'overrides_watch': True}
|
||||
)
|
||||
|
||||
# Deepcopy should work
|
||||
tag_copy2 = deepcopy(tag_with_ds)
|
||||
|
||||
# Test 3: Dict data is copied
|
||||
self.assertEqual(tag_copy2['title'], 'Test Tag With DS', "Tag data should be copied")
|
||||
|
||||
# Test 4: Modifications to copy don't affect original
|
||||
tag_copy2['title'] = 'MODIFIED'
|
||||
self.assertNotEqual(tag_with_ds['title'], 'MODIFIED',
|
||||
"Modifying copy should not affect original")
|
||||
|
||||
# Test 5: Tag with datastore shares it (doesn't copy it)
|
||||
if hasattr(tag_with_ds, '_model__datastore'):
|
||||
self.assertIs(tag_copy2._model__datastore, tag_with_ds._model__datastore,
|
||||
"Tag should share __datastore reference like Watch does")
|
||||
|
||||
def test_watch_copy_performance(self):
|
||||
"""
|
||||
Verify that our __deepcopy__ implementation doesn't cause performance issues.
|
||||
|
||||
With the fix, deepcopy should be fast because we're sharing datastore
|
||||
instead of copying it.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Create a watch with large datastore (many watches)
|
||||
mock_datastore = {
|
||||
'settings': {'application': {}},
|
||||
'watching': {}
|
||||
}
|
||||
|
||||
# Add 100 watches to the datastore
|
||||
for i in range(100):
|
||||
w = Watch.model(
|
||||
__datastore=mock_datastore,
|
||||
datastore_path='/tmp/test',
|
||||
default={'url': f'https://example{i}.com'}
|
||||
)
|
||||
mock_datastore['watching'][w['uuid']] = w
|
||||
|
||||
# Time how long deepcopy takes
|
||||
watch = list(mock_datastore['watching'].values())[0]
|
||||
|
||||
start = time.time()
|
||||
for _ in range(10):
|
||||
_ = deepcopy(watch)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Should be fast (< 0.1 seconds for 10 copies)
|
||||
# If datastore was copied, it would take much longer
|
||||
self.assertLess(elapsed, 0.5,
|
||||
f"Deepcopy too slow ({elapsed:.3f}s for 10 copies) - might be copying datastore")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: cs\n"
|
||||
@@ -327,6 +327,14 @@ msgstr "Nastavit na"
|
||||
msgid "to disable"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Limit collection of history snapshots for each watch to this number of history items."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr ""
|
||||
@@ -345,6 +353,10 @@ msgstr ""
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "Vyberte výchozí proxy pro všechny monitory"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr ""
|
||||
@@ -654,10 +666,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "Vyberte výchozí proxy pro všechny monitory"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "Verze Pythonu:"
|
||||
@@ -983,7 +991,12 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
@@ -1232,6 +1245,10 @@ msgid ""
|
||||
"your filter will not work anymore."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Set to empty to use system settings default"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgstr ""
|
||||
@@ -1580,6 +1597,18 @@ msgstr "zobrazeno <b>{start} - {end}</b> {record_name} z celkem <b>{total}</b>"
|
||||
msgid "records"
|
||||
msgstr "záznamy"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "Více informací"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr "Přidejte nové monitory zjišťování změn webové stránky"
|
||||
@@ -1592,18 +1621,6 @@ msgstr "Monitorovat tuto URL!"
|
||||
msgid "Edit first then Watch"
|
||||
msgstr "Upravit a monitorovat"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Create a shareable link"
|
||||
msgstr "Vytvořte odkaz ke sdílení"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Tip: You can also add 'shared' watches."
|
||||
msgstr "Tip: Můžete také přidat „sdílené“ monitory."
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "Více informací"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
msgstr "Pauza"
|
||||
@@ -2117,6 +2134,10 @@ msgstr "Přiřaďte kteroukoli z následujících možností"
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "V seznamu použijte stránku <title>"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Body must be empty when Request Method is set to GET"
|
||||
msgstr "Když je metoda požadavku nastavena na GET, tělo musí být prázdné"
|
||||
@@ -2439,15 +2460,20 @@ msgstr "Změny textu webové stránky/HTML, JSON a PDF"
|
||||
msgid "Detects all text changes where possible"
|
||||
msgstr "Detekuje všechny změny textu, kde je to možné"
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
msgstr "Tělo pro všechna oznámení — Můžete použít"
|
||||
@@ -3056,3 +3082,12 @@ msgstr "Hlavní nastavení"
|
||||
#~ msgid "Cleared snapshot history for all watches"
|
||||
#~ msgstr "Vymazat/resetovat historii"
|
||||
|
||||
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Create a shareable link"
|
||||
#~ msgstr "Vytvořte odkaz ke sdílení"
|
||||
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "Tip: Můžete také přidat „sdílené“ monitory."
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-14 03:57+0100\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: de\n"
|
||||
@@ -333,6 +333,14 @@ msgstr "Setzen auf"
|
||||
msgid "to disable"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Limit collection of history snapshots for each watch to this number of history items."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr ""
|
||||
@@ -351,6 +359,10 @@ msgstr ""
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "Wählen Sie einen Standard-Proxy für alle Überwachungen"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr ""
|
||||
@@ -664,10 +676,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "Wählen Sie einen Standard-Proxy für alle Überwachungen"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "Python-Version:"
|
||||
@@ -999,8 +1007,13 @@ msgstr "In den Modus {} gewechselt."
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
msgstr "Das Bearbeitungsformular für den Prozessor/das Plugin „{}“ kann nicht geladen werden. Fehlt das Plugin?"
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
msgid "Updated watch - unpaused!"
|
||||
@@ -1254,6 +1267,10 @@ msgstr ""
|
||||
"Sendet eine Benachrichtigung, wenn der Filter auf der Seite nicht mehr sichtbar ist. So wissen Sie, wann sich die "
|
||||
"Seite geändert hat und Ihr Filter nicht mehr funktioniert."
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Set to empty to use system settings default"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgstr "Methode (default), bei der Ihre überwachte Website kein Javascript zum Rendern benötigt."
|
||||
@@ -1618,6 +1635,18 @@ msgstr "zeige <b>{start} - {end}</b> {record_name} von insgesamt <b>{total}</b>"
|
||||
msgid "records"
|
||||
msgstr "Einträge"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "Weitere Informationen"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr "Fügen Sie eine neue Überwachung zur Erkennung von Webseitenänderungen hinzu"
|
||||
@@ -1630,18 +1659,6 @@ msgstr "Diese URL überwachen!"
|
||||
msgid "Edit first then Watch"
|
||||
msgstr "Bearbeiten > Überwachen"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Create a shareable link"
|
||||
msgstr "Erstellen Sie einen Link zum Teilen"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Tip: You can also add 'shared' watches."
|
||||
msgstr "Tipp: Sie können auch „gemeinsame“ Überwachungen hinzufügen."
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "Weitere Informationen"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
msgstr "Pause"
|
||||
@@ -2162,6 +2179,10 @@ msgstr "Entspricht einer der folgenden Bedingungen"
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Verwenden Sie Seite <Titel> in der Liste"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Body must be empty when Request Method is set to GET"
|
||||
msgstr "Der Textkörper muss leer sein, wenn die Anforderungsmethode auf GET gesetzt ist"
|
||||
@@ -2486,15 +2507,20 @@ msgstr "Änderungen an Webseitentext/HTML, JSON und PDF"
|
||||
msgid "Detects all text changes where possible"
|
||||
msgstr "Erkennt nach Möglichkeit alle Textänderungen"
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr "Fehler beim Abrufen der Metadaten für {}"
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr "Das Protokoll wird nicht unterstützt oder das URL-Format ist ungültig."
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
msgstr "Inhalt für alle Benachrichtigungen — Sie können verwenden"
|
||||
@@ -3171,3 +3197,12 @@ msgstr "Haupteinstellungen"
|
||||
#~ msgid "No watches available to recheck."
|
||||
#~ msgstr "Keine Überwachungen verfügbar, um erneut zu überprüfen."
|
||||
|
||||
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
#~ msgstr "Das Bearbeitungsformular für den Prozessor/das Plugin „{}“ kann nicht geladen werden. Fehlt das Plugin?"
|
||||
|
||||
#~ msgid "Create a shareable link"
|
||||
#~ msgstr "Erstellen Sie einen Link zum Teilen"
|
||||
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "Tipp: Sie können auch „gemeinsame“ Überwachungen hinzufügen."
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: changedetection.io\n"
|
||||
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
|
||||
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-12 16:33+0100\n"
|
||||
"Last-Translator: British English Translation Team\n"
|
||||
"Language: en_GB\n"
|
||||
@@ -325,6 +325,14 @@ msgstr ""
|
||||
msgid "to disable"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Limit collection of history snapshots for each watch to this number of history items."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr ""
|
||||
@@ -341,6 +349,10 @@ msgstr ""
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr ""
|
||||
@@ -650,10 +662,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr ""
|
||||
@@ -979,7 +987,12 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
@@ -1228,6 +1241,10 @@ msgid ""
|
||||
"your filter will not work anymore."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Set to empty to use system settings default"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgstr ""
|
||||
@@ -1576,6 +1593,18 @@ msgstr ""
|
||||
msgid "records"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr ""
|
||||
@@ -1588,18 +1617,6 @@ msgstr ""
|
||||
msgid "Edit first then Watch"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Create a shareable link"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Tip: You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
msgstr ""
|
||||
@@ -2113,6 +2130,10 @@ msgstr ""
|
||||
msgid "Use page <title> in list"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Body must be empty when Request Method is set to GET"
|
||||
msgstr ""
|
||||
@@ -2435,15 +2456,20 @@ msgstr ""
|
||||
msgid "Detects all text changes where possible"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
msgstr ""
|
||||
@@ -3001,3 +3027,12 @@ msgstr ""
|
||||
#~ msgid "No watches available to recheck."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Create a shareable link"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
|
||||
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-12 16:37+0100\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: en_US\n"
|
||||
@@ -325,6 +325,14 @@ msgstr ""
|
||||
msgid "to disable"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Limit collection of history snapshots for each watch to this number of history items."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr ""
|
||||
@@ -341,6 +349,10 @@ msgstr ""
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr ""
|
||||
@@ -650,10 +662,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr ""
|
||||
@@ -979,7 +987,12 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
@@ -1228,6 +1241,10 @@ msgid ""
|
||||
"your filter will not work anymore."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Set to empty to use system settings default"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgstr ""
|
||||
@@ -1576,6 +1593,18 @@ msgstr ""
|
||||
msgid "records"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr ""
|
||||
@@ -1588,18 +1617,6 @@ msgstr ""
|
||||
msgid "Edit first then Watch"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Create a shareable link"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Tip: You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
msgstr ""
|
||||
@@ -2113,6 +2130,10 @@ msgstr ""
|
||||
msgid "Use page <title> in list"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Body must be empty when Request Method is set to GET"
|
||||
msgstr ""
|
||||
@@ -2435,15 +2456,20 @@ msgstr ""
|
||||
msgid "Detects all text changes where possible"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
msgstr ""
|
||||
@@ -3001,3 +3027,12 @@ msgstr ""
|
||||
#~ msgid "No watches available to recheck."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Create a shareable link"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: fr\n"
|
||||
@@ -327,6 +327,14 @@ msgstr "Définir à"
|
||||
msgid "to disable"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Limit collection of history snapshots for each watch to this number of history items."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr ""
|
||||
@@ -345,6 +353,10 @@ msgstr ""
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "Choisir un proxy par défaut pour tous les moniteurs"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr ""
|
||||
@@ -654,10 +666,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "Choisir un proxy par défaut pour tous les moniteurs"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "Version Python :"
|
||||
@@ -983,7 +991,12 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
@@ -1234,6 +1247,10 @@ msgid ""
|
||||
"your filter will not work anymore."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Set to empty to use system settings default"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgstr ""
|
||||
@@ -1582,6 +1599,18 @@ msgstr "affichage de <b>{start} - {end}</b> {record_name} sur un total de <b>{to
|
||||
msgid "records"
|
||||
msgstr "enregistrements"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "Plus d'informations"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr "Ajouter une nouvelle surveillance de détection de changement de page Web"
|
||||
@@ -1594,18 +1623,6 @@ msgstr "Surveillez cette URL !"
|
||||
msgid "Edit first then Watch"
|
||||
msgstr "Modifier > Surveiller"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Create a shareable link"
|
||||
msgstr "Créer un lien partageable"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Tip: You can also add 'shared' watches."
|
||||
msgstr "Astuce : Vous pouvez également ajouter des montres « partagées »."
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "Plus d'informations"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
msgstr "Pause"
|
||||
@@ -2123,6 +2140,10 @@ msgstr "Faites correspondre l'un des éléments suivants"
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Utiliser la page <titre> dans la liste"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Body must be empty when Request Method is set to GET"
|
||||
msgstr "Le corps doit être vide lorsque la méthode de requête est définie sur GET"
|
||||
@@ -2445,15 +2466,20 @@ msgstr "Modifications du texte de la page Web/HTML, JSON et PDF"
|
||||
msgid "Detects all text changes where possible"
|
||||
msgstr "Détecte toutes les modifications de texte lorsque cela est possible"
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
msgstr "Corps pour toutes les notifications — Vous pouvez utiliser"
|
||||
@@ -3064,3 +3090,12 @@ msgstr "Paramètres principaux"
|
||||
#~ msgid "Cleared snapshot history for all watches"
|
||||
#~ msgstr "Effacer/réinitialiser l'historique"
|
||||
|
||||
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Create a shareable link"
|
||||
#~ msgstr "Créer un lien partageable"
|
||||
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "Astuce : Vous pouvez également ajouter des montres « partagées »."
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-02 15:32+0100\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: it\n"
|
||||
@@ -327,6 +327,14 @@ msgstr ""
|
||||
msgid "to disable"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Limit collection of history snapshots for each watch to this number of history items."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr ""
|
||||
@@ -343,6 +351,10 @@ msgstr "Consenti accesso alla pagina cronologia quando la password è attiva (ut
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr ""
|
||||
@@ -652,10 +664,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr ""
|
||||
@@ -981,7 +989,12 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
@@ -1230,6 +1243,10 @@ msgid ""
|
||||
"your filter will not work anymore."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Set to empty to use system settings default"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgstr ""
|
||||
@@ -1578,6 +1595,18 @@ msgstr "visualizzando <b>{start} - {end}</b> {record_name} su un totale di <b>{t
|
||||
msgid "records"
|
||||
msgstr "record"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "Maggiori informazioni"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr "Aggiungi un nuovo monitoraggio modifiche pagina web"
|
||||
@@ -1590,18 +1619,6 @@ msgstr "Monitora questo URL!"
|
||||
msgid "Edit first then Watch"
|
||||
msgstr "Modifica > Monitora"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Create a shareable link"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Tip: You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "Maggiori informazioni"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
msgstr "Pausa"
|
||||
@@ -2115,6 +2132,10 @@ msgstr "Corrisponde a uno qualsiasi dei seguenti"
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Usa <title> pagina nell'elenco"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Body must be empty when Request Method is set to GET"
|
||||
msgstr "Il corpo deve essere vuoto quando il metodo è impostato su GET"
|
||||
@@ -2437,15 +2458,20 @@ msgstr "Modifiche testo/HTML, JSON e PDF"
|
||||
msgid "Detects all text changes where possible"
|
||||
msgstr "Rileva tutte le modifiche di testo possibili"
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr "Errore nel recupero metadati per {}"
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr "Protocollo non consentito o formato URL non valido"
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
msgstr "Corpo per tutte le notifiche — Puoi usare"
|
||||
@@ -3036,3 +3062,12 @@ msgstr "Impostazioni principali"
|
||||
#~ msgid "Queue"
|
||||
#~ msgstr "In coda"
|
||||
|
||||
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Create a shareable link"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: ko\n"
|
||||
@@ -325,6 +325,14 @@ msgstr "설정:"
|
||||
msgid "to disable"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Limit collection of history snapshots for each watch to this number of history items."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr ""
|
||||
@@ -341,6 +349,10 @@ msgstr "비밀번호 활성화 시 변경 기록 페이지 액세스 허용 (dif
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "모든 모니터의 기본 프록시 선택"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr ""
|
||||
@@ -650,10 +662,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "모든 모니터의 기본 프록시 선택"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "파이썬 버전:"
|
||||
@@ -979,7 +987,12 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
@@ -1228,6 +1241,10 @@ msgid ""
|
||||
"your filter will not work anymore."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Set to empty to use system settings default"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgstr ""
|
||||
@@ -1576,6 +1593,18 @@ msgstr "총 <b>{total}</b>개 중 <b>{start} - {end}</b>개 {record_name} 표시
|
||||
msgid "records"
|
||||
msgstr "기록"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "추가 정보"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr "새로운 웹 페이지 변경 감지 감시 추가"
|
||||
@@ -1588,18 +1617,6 @@ msgstr "이 URL 모니터!"
|
||||
msgid "Edit first then Watch"
|
||||
msgstr "편집 후 모니터"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Create a shareable link"
|
||||
msgstr "공유 가능한 링크 만들기"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Tip: You can also add 'shared' watches."
|
||||
msgstr "팁: '공유' 시계를 추가할 수도 있습니다."
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "추가 정보"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
msgstr "정지시키다"
|
||||
@@ -2113,6 +2130,10 @@ msgstr "다음 중 하나와 일치"
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "목록의 <제목> 페이지 사용"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Body must be empty when Request Method is set to GET"
|
||||
msgstr "요청 방법이 GET으로 설정된 경우 본문이 비어 있어야 합니다."
|
||||
@@ -2435,15 +2456,20 @@ msgstr "웹페이지 텍스트/HTML, JSON 및 PDF 변경"
|
||||
msgid "Detects all text changes where possible"
|
||||
msgstr "가능한 경우 모든 텍스트 변경 사항을 감지합니다."
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
msgstr "모든 알림 본문 — 사용 가능:"
|
||||
@@ -3157,3 +3183,12 @@ msgstr "기본 설정"
|
||||
#~ msgid "Cleared snapshot history for all watches"
|
||||
#~ msgstr "기록 지우기/재설정"
|
||||
|
||||
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Create a shareable link"
|
||||
#~ msgstr "공유 가능한 링크 만들기"
|
||||
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "팁: '공유' 시계를 추가할 수도 있습니다."
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: changedetection.io 0.52.8\n"
|
||||
"Project-Id-Version: changedetection.io 0.52.9\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-01-22 06:29+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\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"
|
||||
@@ -324,6 +324,14 @@ msgstr ""
|
||||
msgid "to disable"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Limit collection of history snapshots for each watch to this number of history items."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr ""
|
||||
@@ -340,6 +348,10 @@ msgstr ""
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr ""
|
||||
@@ -649,10 +661,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr ""
|
||||
@@ -978,7 +986,12 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
@@ -1227,6 +1240,10 @@ msgid ""
|
||||
"your filter will not work anymore."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Set to empty to use system settings default"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgstr ""
|
||||
@@ -1575,6 +1592,18 @@ msgstr ""
|
||||
msgid "records"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr ""
|
||||
@@ -1587,18 +1616,6 @@ msgstr ""
|
||||
msgid "Edit first then Watch"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Create a shareable link"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Tip: You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
msgstr ""
|
||||
@@ -2112,6 +2129,10 @@ msgstr ""
|
||||
msgid "Use page <title> in list"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Body must be empty when Request Method is set to GET"
|
||||
msgstr ""
|
||||
@@ -2434,15 +2455,20 @@ msgstr ""
|
||||
msgid "Detects all text changes where possible"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-18 21:31+0800\n"
|
||||
"Last-Translator: 吾爱分享 <admin@wuaishare.cn>\n"
|
||||
"Language: zh\n"
|
||||
@@ -325,6 +325,14 @@ msgstr "设置为"
|
||||
msgid "to disable"
|
||||
msgstr "以禁用"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Limit collection of history snapshots for each watch to this number of history items."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr "为你的 changedetection.io 应用启用密码保护。"
|
||||
@@ -341,6 +349,10 @@ msgstr "启用密码时允许访问监视器更改历史页面(便于共享差
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr "当请求无内容返回,或 HTML 不包含任何文本时,是否视为变更?"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "为所有监视器选择默认代理"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr "用于通知链接中的"
|
||||
@@ -650,10 +662,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr "带认证的 SOCKS5 代理仅支持“明文请求”抓取器,其他抓取器请改为白名单 IP"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "为所有监视器选择默认代理"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "Python 版本:"
|
||||
@@ -979,8 +987,13 @@ msgstr "已切换到模式 - {}。"
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
msgstr "无法加载处理器/插件 '{}' 的编辑表单,插件是否缺失?"
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
msgid "Updated watch - unpaused!"
|
||||
@@ -1228,6 +1241,10 @@ msgid ""
|
||||
"your filter will not work anymore."
|
||||
msgstr "当页面上找不到该过滤器时发送通知,便于知晓页面已变化且过滤器不再适用。"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Set to empty to use system settings default"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgstr "方式(默认),适用于无需 JavaScript 渲染的网站。"
|
||||
@@ -1576,6 +1593,18 @@ msgstr "显示第 <b>{start} - {end}</b> 条{record_name},共 <b>{total}</b>
|
||||
msgid "records"
|
||||
msgstr "记录"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "更多信息"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr "新增网页变更监控"
|
||||
@@ -1588,18 +1617,6 @@ msgstr "监控此 URL!"
|
||||
msgid "Edit first then Watch"
|
||||
msgstr "先编辑,再监控"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Create a shareable link"
|
||||
msgstr "创建可分享链接"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Tip: You can also add 'shared' watches."
|
||||
msgstr "提示:你也可以添加“共享”的监控项。"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "更多信息"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
msgstr "暂停"
|
||||
@@ -2113,6 +2130,10 @@ msgstr "匹配以下任意"
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "列表中使用页面 <title>"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Body must be empty when Request Method is set to GET"
|
||||
msgstr "当请求方法为 GET 时,请求正文必须为空"
|
||||
@@ -2435,15 +2456,20 @@ msgstr "网页文本/HTML、JSON 和 PDF 变更"
|
||||
msgid "Detects all text changes where possible"
|
||||
msgstr "尽可能检测所有文本变更"
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr "获取 {} 的元数据失败"
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr "监控协议不允许或 URL 格式无效"
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
msgstr "所有通知的正文 — 您可以使用"
|
||||
@@ -2986,3 +3012,12 @@ msgstr "否"
|
||||
msgid "Main settings"
|
||||
msgstr "主设置"
|
||||
|
||||
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
#~ msgstr "无法加载处理器/插件 '{}' 的编辑表单,插件是否缺失?"
|
||||
|
||||
#~ msgid "Create a shareable link"
|
||||
#~ msgstr "创建可分享链接"
|
||||
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "提示:你也可以添加“共享”的监控项。"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-01-22 06:19+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-15 12:00+0800\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: zh_Hant_TW\n"
|
||||
@@ -325,6 +325,14 @@ msgstr "設置為"
|
||||
msgid "to disable"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Limit collection of history snapshots for each watch to this number of history items."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr ""
|
||||
@@ -341,6 +349,10 @@ msgstr "啟用密碼時允許匿名存取監測歷史頁面"
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "為所有監測任務選擇預設代理伺服器"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr ""
|
||||
@@ -650,10 +662,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "為所有監測任務選擇預設代理伺服器"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "Python 版本:"
|
||||
@@ -979,8 +987,13 @@ msgstr "已切換至模式 - {}。"
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
msgstr "無法載入處理器 / 外掛 '{}' 的編輯表單,外掛是否遺失?"
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
msgid "Updated watch - unpaused!"
|
||||
@@ -1228,6 +1241,10 @@ msgid ""
|
||||
"your filter will not work anymore."
|
||||
msgstr "當頁面上找不到過濾器時發送通知,這有助於了解頁面何時變更導致您的過濾器失效。"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Set to empty to use system settings default"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgstr "方法(預設),適用於您監測的網站不需要 Javascript 渲染的情況。"
|
||||
@@ -1576,6 +1593,18 @@ msgstr "顯示第 <b>{start} - {end}</b> 條{record_name},共 <b>{total}</b>
|
||||
msgid "records"
|
||||
msgstr "記錄"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "更多資訊"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "You can also add 'shared' watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr "新增網頁變更檢測任務"
|
||||
@@ -1588,18 +1617,6 @@ msgstr "監測此 URL!"
|
||||
msgid "Edit first then Watch"
|
||||
msgstr "先編輯後監測"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Create a shareable link"
|
||||
msgstr "建立可分享連結"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Tip: You can also add 'shared' watches."
|
||||
msgstr "提示:您也可以新增「共享」監測任務。"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "More info"
|
||||
msgstr "更多資訊"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
msgstr "暫停"
|
||||
@@ -2113,6 +2130,10 @@ msgstr "符合以下任一條件"
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "在列表中使用頁面 <title>"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Body must be empty when Request Method is set to GET"
|
||||
msgstr "當請求方法設為 GET 時,內容必須為空"
|
||||
@@ -2435,15 +2456,20 @@ msgstr "網頁文字 / HTML、JSON 和 PDF 變更"
|
||||
msgid "Detects all text changes where possible"
|
||||
msgstr "盡可能檢測所有文字變更"
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr "讀取 {} 的中繼資料時發生錯誤"
|
||||
|
||||
#: changedetectionio/store.py
|
||||
#: changedetectionio/store/__init__.py
|
||||
msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr "監測協定不被允許或 URL 格式無效"
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
msgstr "所有通知的內文 — 您可以使用"
|
||||
@@ -3115,3 +3141,12 @@ msgstr "主設定"
|
||||
#~ msgid "No watches available to recheck."
|
||||
#~ msgstr "沒有可複查的監測任務。"
|
||||
|
||||
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
|
||||
#~ msgstr "無法載入處理器 / 外掛 '{}' 的編輯表單,外掛是否遺失?"
|
||||
|
||||
#~ msgid "Create a shareable link"
|
||||
#~ msgstr "建立可分享連結"
|
||||
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "提示:您也可以新增「共享」監測任務。"
|
||||
|
||||
|
||||
@@ -142,11 +142,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
processor = watch.get('processor', 'text_json_diff')
|
||||
|
||||
# Init a new 'difference_detection_processor'
|
||||
try:
|
||||
processor_module = importlib.import_module(f"changedetectionio.processors.{processor}.processor")
|
||||
except ModuleNotFoundError as e:
|
||||
print(f"Processor module '{processor}' not found.")
|
||||
raise e
|
||||
# Use get_processor_module() to support both built-in and plugin processors
|
||||
from changedetectionio.processors import get_processor_module
|
||||
processor_module = get_processor_module(processor)
|
||||
|
||||
if not processor_module:
|
||||
error_msg = f"Processor module '{processor}' not found."
|
||||
logger.error(error_msg)
|
||||
raise ModuleNotFoundError(error_msg)
|
||||
|
||||
update_handler = processor_module.perform_site_check(datastore=datastore,
|
||||
watch_uuid=uuid)
|
||||
@@ -365,8 +368,9 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
logger.error(f"Exception (BrowserStepsInUnsupportedFetcher) reached processing watch UUID: {uuid}")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Worker {worker_id} exception processing watch UUID: {uuid}")
|
||||
logger.error(str(e))
|
||||
logger.exception(f"Worker {worker_id} full exception details:")
|
||||
datastore.update_watch(uuid=uuid, update_obj={'last_error': "Exception: " + str(e)})
|
||||
process_changedetection_results = False
|
||||
|
||||
@@ -385,8 +389,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
if not datastore.data['watching'].get(uuid):
|
||||
continue
|
||||
|
||||
logger.debug(f"Processing watch UUID: {uuid} - xpath_data length returned {len(update_handler.xpath_data) if update_handler.xpath_data else 'empty.'}")
|
||||
if process_changedetection_results:
|
||||
logger.debug(f"Processing watch UUID: {uuid} - xpath_data length returned {len(update_handler.xpath_data) if update_handler and update_handler.xpath_data else 'empty.'}")
|
||||
if update_handler and process_changedetection_results:
|
||||
try:
|
||||
datastore.update_watch(uuid=uuid, update_obj=update_obj)
|
||||
|
||||
@@ -430,64 +434,62 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
await send_content_changed_notification(uuid, notification_q, datastore)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.critical(f"Worker {worker_id} exception in process_changedetection_results")
|
||||
logger.critical(str(e))
|
||||
logger.exception(f"Worker {worker_id} full exception details:")
|
||||
datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)})
|
||||
|
||||
# Always record attempt count
|
||||
count = watch.get('check_count', 0) + 1
|
||||
if update_handler: # Could be none or empty if the processor was not found
|
||||
# Always record page title (used in notifications, and can change even when the content is the same)
|
||||
if update_obj.get('content-type') and 'html' in update_obj.get('content-type'):
|
||||
try:
|
||||
page_title = html_tools.extract_title(data=update_handler.fetcher.content)
|
||||
if page_title:
|
||||
page_title = page_title.strip()[:2000]
|
||||
logger.debug(f"UUID: {uuid} Page <title> is '{page_title}'")
|
||||
datastore.update_watch(uuid=uuid, update_obj={'page_title': page_title})
|
||||
except Exception as e:
|
||||
logger.exception(f"Worker {worker_id} full exception details:")
|
||||
logger.warning(f"UUID: {uuid} Exception when extracting <title> - {str(e)}")
|
||||
|
||||
# Always record page title (used in notifications, and can change even when the content is the same)
|
||||
if update_obj.get('content-type') and 'html' in update_obj.get('content-type'):
|
||||
# Record server header
|
||||
try:
|
||||
page_title = html_tools.extract_title(data=update_handler.fetcher.content)
|
||||
if page_title:
|
||||
page_title = page_title.strip()[:2000]
|
||||
logger.debug(f"UUID: {uuid} Page <title> is '{page_title}'")
|
||||
datastore.update_watch(uuid=uuid, update_obj={'page_title': page_title})
|
||||
server_header = update_handler.fetcher.headers.get('server', '').strip().lower()[:255]
|
||||
datastore.update_watch(uuid=uuid, update_obj={'remote_server_reply': server_header})
|
||||
except Exception as e:
|
||||
logger.warning(f"UUID: {uuid} Exception when extracting <title> - {str(e)}")
|
||||
pass
|
||||
|
||||
# Record server header
|
||||
try:
|
||||
server_header = update_handler.fetcher.headers.get('server', '').strip().lower()[:255]
|
||||
datastore.update_watch(uuid=uuid, update_obj={'remote_server_reply': server_header})
|
||||
except Exception as e:
|
||||
pass
|
||||
# Store favicon if necessary
|
||||
if update_handler.fetcher.favicon_blob and update_handler.fetcher.favicon_blob.get('base64'):
|
||||
watch.bump_favicon(url=update_handler.fetcher.favicon_blob.get('url'),
|
||||
favicon_base_64=update_handler.fetcher.favicon_blob.get('base64')
|
||||
)
|
||||
|
||||
# Store favicon if necessary
|
||||
if update_handler.fetcher.favicon_blob and update_handler.fetcher.favicon_blob.get('base64'):
|
||||
watch.bump_favicon(url=update_handler.fetcher.favicon_blob.get('url'),
|
||||
favicon_base_64=update_handler.fetcher.favicon_blob.get('base64')
|
||||
)
|
||||
datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3),
|
||||
'check_count': count})
|
||||
|
||||
datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3),
|
||||
'check_count': count})
|
||||
# NOW clear fetcher content - after all processing is complete
|
||||
# This is the last point where we need the fetcher data
|
||||
if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:
|
||||
update_handler.fetcher.clear_content()
|
||||
logger.debug(f"Cleared fetcher content for UUID {uuid}")
|
||||
|
||||
# NOW clear fetcher content - after all processing is complete
|
||||
# This is the last point where we need the fetcher data
|
||||
if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:
|
||||
update_handler.fetcher.clear_content()
|
||||
logger.debug(f"Cleared fetcher content for UUID {uuid}")
|
||||
# Explicitly delete update_handler to free all references
|
||||
if update_handler:
|
||||
del update_handler
|
||||
update_handler = None
|
||||
|
||||
# Explicitly delete update_handler to free all references
|
||||
if update_handler:
|
||||
del update_handler
|
||||
update_handler = None
|
||||
|
||||
# Force aggressive memory cleanup after clearing
|
||||
# Force garbage collection
|
||||
import gc
|
||||
gc.collect()
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.CDLL('libc.so.6').malloc_trim(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.error(f"Worker {worker_id} unexpected error processing {uuid}: {e}")
|
||||
logger.error(f"Worker {worker_id} traceback:", exc_info=True)
|
||||
|
||||
logger.exception(f"Worker {worker_id} full exception details:")
|
||||
|
||||
# Also update the watch with error information
|
||||
if datastore and uuid in datastore.data['watching']:
|
||||
datastore.update_watch(uuid=uuid, update_obj={'last_error': f"Worker error: {str(e)}"})
|
||||
@@ -495,49 +497,43 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
finally:
|
||||
# Always cleanup - this runs whether there was an exception or not
|
||||
if uuid:
|
||||
# Call quit() as backup (Puppeteer/Playwright have internal cleanup, but this acts as safety net)
|
||||
try:
|
||||
if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:
|
||||
await update_handler.fetcher.quit(watch=watch)
|
||||
except Exception as e:
|
||||
logger.error(f"Exception while cleaning/quit after calling browser: {e}")
|
||||
logger.exception(f"Worker {worker_id} full exception details:")
|
||||
|
||||
try:
|
||||
# Release UUID from processing (thread-safe)
|
||||
worker_pool.release_uuid_from_processing(uuid, worker_id=worker_id)
|
||||
|
||||
|
||||
# Send completion signal
|
||||
if watch:
|
||||
#logger.info(f"Worker {worker_id} sending completion signal for UUID {watch['uuid']}")
|
||||
watch_check_update.send(watch_uuid=watch['uuid'])
|
||||
|
||||
# Explicitly clean up update_handler and all its references
|
||||
# Clean up all memory references BEFORE garbage collection
|
||||
if update_handler:
|
||||
# Clear fetcher content using the proper method
|
||||
if hasattr(update_handler, 'fetcher') and update_handler.fetcher:
|
||||
update_handler.fetcher.clear_content()
|
||||
|
||||
# Clear processor references
|
||||
if hasattr(update_handler, 'content_processor'):
|
||||
update_handler.content_processor = None
|
||||
|
||||
del update_handler
|
||||
update_handler = None
|
||||
|
||||
# Clear local contents variable if it still exists
|
||||
# Clear large content variables
|
||||
if 'contents' in locals():
|
||||
del contents
|
||||
|
||||
# Note: We don't set watch = None here because:
|
||||
# 1. watch is just a local reference to datastore.data['watching'][uuid]
|
||||
# 2. Setting it to None doesn't affect the datastore
|
||||
# 3. GC can't collect the object anyway (still referenced by datastore)
|
||||
# 4. It would just cause confusion
|
||||
|
||||
# Force garbage collection after cleanup
|
||||
# Force garbage collection after all references are cleared
|
||||
import gc
|
||||
gc.collect()
|
||||
|
||||
logger.debug(f"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s")
|
||||
except Exception as cleanup_error:
|
||||
logger.error(f"Worker {worker_id} error during cleanup: {cleanup_error}")
|
||||
logger.exception(f"Worker {worker_id} full exception details:")
|
||||
|
||||
del(uuid)
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ services:
|
||||
# Log output levels: TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL
|
||||
# - LOGGER_LEVEL=TRACE
|
||||
#
|
||||
# Plugins! See https://changedetection.io/plugins for more plugins.
|
||||
# Install additional Python packages (processor plugins, etc.)
|
||||
# Example: Install the OSINT reconnaissance processor plugin
|
||||
# - EXTRA_PACKAGES=changedetection.io-osint-processor
|
||||
# Multiple packages can be installed by separating with spaces:
|
||||
# - EXTRA_PACKAGES=changedetection.io-osint-processor another-plugin
|
||||
#
|
||||
#
|
||||
# Uncomment below and the "sockpuppetbrowser" to use a real Chrome browser (It uses the "playwright" protocol)
|
||||
# - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
|
||||
|
||||
28
docker-entrypoint.sh
Executable file
28
docker-entrypoint.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/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"
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
# Execute the main command
|
||||
exec "$@"
|
||||
@@ -51,9 +51,9 @@ linkify-it-py
|
||||
|
||||
# - Needed for apprise/spush, and maybe others? hopefully doesnt trigger a rust compile.
|
||||
# - Requires extra wheel for rPi, adds build time for arm/v8 which is not in piwheels
|
||||
# Pinned to 43.0.1 for ARM compatibility (45.x may not have pre-built ARM wheels)
|
||||
# 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==46.0.3
|
||||
cryptography==44.0.0
|
||||
|
||||
# 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
|
||||
@@ -70,7 +70,7 @@ lxml >=4.8.0,!=5.2.0,!=5.2.1,<7
|
||||
|
||||
# XPath 2.0-3.1 support - 4.2.0 had issues, 4.1.5 stable
|
||||
# Consider updating to latest stable version periodically
|
||||
elementpath==5.1.0
|
||||
elementpath==5.1.1
|
||||
|
||||
# For fast image comparison in screenshot change detection
|
||||
# opencv-python-headless is OPTIONAL (excluded from requirements.txt)
|
||||
|
||||
Reference in New Issue
Block a user