Compare commits

..

6 Commits

Author SHA1 Message Date
dgtlmoon
3d5036e08f text fix 2026-04-16 15:01:34 +02:00
dgtlmoon
75c03db050 oops 2026-04-16 14:53:49 +02:00
dgtlmoon
541c5e09dc forwards security fix 2026-04-16 14:49:49 +02:00
dgtlmoon
006e5cb7a7 fix stats 2026-04-16 14:47:47 +02:00
dgtlmoon
c2f06f574b WIP 2026-04-16 14:41:30 +02:00
dgtlmoon
f3c68c2311 LLM implementation - attempt 3 2026-04-16 11:06:30 +02:00
82 changed files with 3596 additions and 4860 deletions

View File

@@ -99,7 +99,7 @@ jobs:
- name: Run Unit Tests
run: |
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest tests/unit/'
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest tests/unit/ tests/llm/'
# Basic pytest tests with ancillary services
basic-tests:

View File

@@ -76,6 +76,19 @@ def construct_blueprint(datastore: ChangeDetectionStore):
datastore.data['settings']['application'].update(app_update)
# Save LLM config separately under settings.application.llm
llm_data = form.data.get('llm') or {}
llm_config = {
'model': (llm_data.get('llm_model') or '').strip(),
'api_key': (llm_data.get('llm_api_key') or '').strip(),
'api_base': (llm_data.get('llm_api_base') or '').strip(),
}
# Only store if a model is set
if llm_config['model']:
datastore.data['settings']['application']['llm'] = llm_config
else:
datastore.data['settings']['application'].pop('llm', None)
# Handle dynamic worker count adjustment
old_worker_count = datastore.data['settings']['requests'].get('workers', 1)
new_worker_count = form.data['requests'].get('workers', 1)
@@ -164,9 +177,15 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Instantiate the form with existing settings
plugin_forms[plugin_id] = form_class(data=settings)
from changedetectionio.llm.evaluator import get_llm_config as _get_llm_cfg, llm_configured_via_env
llm_config = _get_llm_cfg(datastore) or {}
llm_env_configured = llm_configured_via_env()
output = render_template("settings.html",
active_plugins=active_plugins,
api_key=datastore.data['settings']['application'].get('api_access_token'),
llm_config=llm_config,
llm_env_configured=llm_env_configured,
python_version=python_version,
uptime_seconds=uptime_seconds,
available_timezones=sorted(available_timezones()),

View File

@@ -33,6 +33,7 @@
<li class="tab"><a href="#plugin-{{ tab.plugin_id }}">{{ tab.tab_label }}</a></li>
{% endfor %}
{% endif %}
<li class="tab"><a href="#ai">{{ _('AI/LLM') }}</a></li>
<li class="tab"><a href="#info">{{ _('Info') }}</a></li>
</ul>
</div>
@@ -263,7 +264,7 @@ nav
</div>
<div>
{{ render_field(form.application.form.rss_template_override) }}
{{ show_token_placeholders(extra_notification_token_placeholder_info=extra_notification_token_placeholder_info, suffix="-rss") }}
{{ show_token_placeholders(extra_notification_token_placeholder_info=extra_notification_token_placeholder_info, suffix="-rss", settings_application=settings_application) }}
</div>
</div>
<br>
@@ -392,6 +393,7 @@ nav
</div>
{% endfor %}
{% endif %}
{% include 'settings_llm_tab.html' %}
<div class="tab-pane-inner" id="info">
<p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>

View File

@@ -0,0 +1,85 @@
{% from '_helpers.html' import render_field %}
{#
AI/LLM settings tab content — included from settings.html.
Requires template context: form, llm_config, llm_env_configured
#}
<div class="tab-pane-inner" id="ai">
<fieldset>
<p>{{ _('Configure an AI/LLM provider to enable intent-based change filtering. Each watch or tag can have a plain-English intent — the AI evaluates every detected change against it and only notifies you when it matches.') }}</p>
{% if llm_env_configured %}
{# Config is coming from environment variables — hide the form #}
<div class="inline-warning" style="margin-bottom: 1em;">
<img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="{{ _('Note') }}">
{{ _('AI/LLM is configured via environment variables') }}
(<code>LLM_MODEL={{ llm_config.get('model', '') }}</code>{% if llm_config.get('api_base') %}, <code>LLM_API_BASE={{ llm_config.get('api_base') }}</code>{% endif %}).
{{ _('Remove the') }} <code>LLM_MODEL</code> {{ _('environment variable to configure via this form instead.') }}
</div>
{% else %}
<div class="pure-control-group">
<label>{{ _('Quick preset') }}</label>
<select id="llm-preset" onchange="applyLLMPreset(this.value)" class="pure-input-1-3">
<option value="">— {{ _('choose a preset or type your own below') }} —</option>
<optgroup label="OpenAI">
<option value="gpt-4o-mini|">gpt-4o-mini (fast, affordable)</option>
<option value="gpt-4o|">gpt-4o (most capable)</option>
</optgroup>
<optgroup label="Anthropic">
<option value="claude-3-5-haiku-20251001|">claude-3-5-haiku (fast)</option>
<option value="claude-sonnet-4-5|">claude-sonnet-4-5 (balanced)</option>
</optgroup>
<optgroup label="Google">
<option value="gemini/gemini-2.0-flash|">gemini-2.0-flash</option>
<option value="gemini/gemini-2.0-flash-lite|">gemini-2.0-flash-lite (free tier)</option>
</optgroup>
<optgroup label="OpenRouter (access 200+ models)">
<option value="openrouter/google/gemma-3-12b-it:free|">gemma-3-12b (free)</option>
<option value="openrouter/meta-llama/llama-4-scout:free|">llama-4-scout (free)</option>
<option value="openrouter/mistralai/mistral-small-3.1-24b-instruct:free|">mistral-small (free)</option>
</optgroup>
<optgroup label="{{ _('Local / Self-hosted') }}">
<option value="ollama/llama3.2|http://localhost:11434">ollama/llama3.2 (local)</option>
<option value="ollama/mistral|http://localhost:11434">ollama/mistral (local)</option>
</optgroup>
</select>
</div>
<div class="pure-control-group">
{{ render_field(form.llm.form.llm_model) }}
<span class="pure-form-message-inline">
{{ _('The model string encodes both provider and model — litellm routes automatically. Any') }}
<a href="https://docs.litellm.ai/docs/providers" target="_blank">{{ _('litellm-supported model') }}</a> {{ _('works here.') }}
</span>
</div>
<div class="pure-control-group">
{{ render_field(form.llm.form.llm_api_key) }}
</div>
<div class="pure-control-group">
{{ render_field(form.llm.form.llm_api_base) }}
<span class="pure-form-message-inline">{{ _('Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers.') }}</span>
</div>
{% if llm_config and llm_config.get('model') %}
<div class="pure-control-group">
<span style="color: #4a7c59; font-weight: bold;">
&#10003; {{ _('AI configured:') }} {{ llm_config.get('model') }}
</span>
</div>
{% endif %}
<p class="pure-form-message-inline">
{{ _("Your API key is stored locally and sent only to your chosen provider. On each detected change, the watch's diff and extracted text are sent to the LLM — no full page HTML.") }}
</p>
{% endif %}{# llm_env_configured #}
</fieldset>
</div>
<script>
function applyLLMPreset(value) {
if (!value) return;
var parts = value.split('|');
var modelField = document.querySelector('[name="llm-llm_model"]');
var baseField = document.querySelector('[name="llm-llm_api_base"]');
if (modelField) modelField.value = parts[0] || '';
if (baseField) baseField.value = parts[1] || '';
}
</script>

View File

@@ -5,6 +5,7 @@ from loguru import logger
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.flask_app import login_optionally_required
from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
def construct_blueprint(datastore: ChangeDetectionStore):
@@ -183,6 +184,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
'form': form,
'watch': default,
'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
'llm_configured': bool(_get_llm_config(datastore)),
}
included_content = {}

View File

@@ -2,20 +2,30 @@ from wtforms import (
Form,
StringField,
SubmitField,
TextAreaField,
validators,
)
from wtforms.fields.simple import BooleanField
from flask_babel import lazy_gettext as _l
from changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form
class group_restock_settings_form(restock_settings_form):
overrides_watch = BooleanField(_l('Activate for individual watches in this tag/group?'), default=False)
url_match_pattern = StringField(_l('Auto-apply to watches with URLs matching'),
render_kw={"placeholder": _l("e.g. *://example.com/* or github.com/myorg")})
tag_colour = StringField(_l('Tag colour'), default='')
overrides_watch = BooleanField('Activate for individual watches in this tag/group?', default=False)
url_match_pattern = StringField('Auto-apply to watches with URLs matching',
render_kw={"placeholder": "e.g. *://example.com/* or github.com/myorg"})
tag_colour = StringField('Tag colour', default='')
llm_intent = TextAreaField('AI Change Intent',
validators=[validators.Optional(), validators.Length(max=2000)],
render_kw={"rows": "3",
"placeholder": "e.g. Flag price changes or new product launches across all watches in this group"})
llm_change_summary = TextAreaField('AI Change Summary',
validators=[validators.Optional(), validators.Length(max=2000)],
render_kw={"rows": "3",
"placeholder": "e.g. List what was added or removed. Translate to English."},
default='')
class SingleTag(Form):
name = StringField(_l('Tag name'), [validators.InputRequired()], render_kw={"placeholder": _l("Name")})
save_button = SubmitField(_l('Save'), render_kw={"class": "pure-button pure-button-primary"})
name = StringField('Tag name', [validators.InputRequired()], render_kw={"placeholder": "Name"})
save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})

View File

@@ -88,6 +88,11 @@
<div class="tab-pane-inner" id="filters-and-triggers">
<p>{{ _('These settings are') }} <strong><i>{{ _('added') }}</i></strong> {{ _('to any existing watch configurations.') }}</p>
{# ── AI Intent ─────────────────────────────────────────── #}
{% include "edit/include_llm_intent.html" %}
{# ── end AI Intent ──────────────────────────────────────── #}
{% include "edit/include_subtract.html" %}
<div class="text-filtering border-fieldset">
<h3>{{ _('Text filtering') }}</h3>

View File

@@ -65,7 +65,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
{% for uuid, tag in available_tags %}
<tr id="{{ uuid }}" class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }}">
<td class="watch-controls">
<a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{{ _('Mute notifications') }}" title="{{ _('Mute notifications') }}" class="icon icon-mute" ></a>
<a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a>
</td>
<td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}" class="watch-tag-list tag-{{ tag.title|sanitize_tag_class }}">{{ tag.title }}</a></td>

View File

@@ -128,6 +128,80 @@ def construct_blueprint(datastore: ChangeDetectionStore):
redirect=redirect
)
@diff_blueprint.route("/diff/<uuid_str:uuid>/llm-summary", methods=['GET'])
@login_optionally_required
def diff_llm_summary(uuid):
"""
Generate (or return cached) an AI summary of the diff between two snapshots.
Called via AJAX from the diff page when no cached summary exists.
Returns JSON: {"summary": "...", "error": null} or {"summary": null, "error": "..."}
"""
import difflib
from flask import jsonify
try:
watch = datastore.data['watching'][uuid]
except KeyError:
return jsonify({'summary': None, 'error': 'Watch not found'}), 404
llm_cfg = datastore.data.get('settings', {}).get('application', {}).get('llm', {})
if not llm_cfg.get('model'):
return jsonify({'summary': None, 'error': 'LLM not configured'}), 400
dates = list(watch.history.keys())
if len(dates) < 2:
return jsonify({'summary': None, 'error': 'Not enough history'}), 400
from_version = request.args.get('from_version', dates[-2])
to_version = request.args.get('to_version', dates[-1])
try:
from_text = watch.get_history_snapshot(timestamp=from_version)
to_text = watch.get_history_snapshot(timestamp=to_version)
except Exception as e:
return jsonify({'summary': None, 'error': f'Could not read snapshots: {e}'}), 500
# Plain-text unified diff for LLM consumption
diff_lines = list(difflib.unified_diff(
from_text.splitlines(),
to_text.splitlines(),
lineterm='',
n=3,
))
# Skip the +++/--- header lines
diff_text = '\n'.join(diff_lines[2:]) if len(diff_lines) > 2 else '\n'.join(diff_lines)
if not diff_text.strip():
return jsonify({'summary': None, 'error': 'No differences found'})
from changedetectionio.llm.evaluator import (
summarise_change, get_effective_summary_prompt, compute_summary_cache_key,
)
# Check cache — skip LLM if this exact (diff, prompt) pair was already summarised
effective_prompt = get_effective_summary_prompt(watch, datastore)
cache_key = compute_summary_cache_key(diff_text, effective_prompt)
cached = watch.get_last_llm_diff_summary(cache_key=cache_key)
if cached:
return jsonify({'summary': cached, 'error': None, 'cached': True})
try:
summary = summarise_change(watch, datastore, diff=diff_text, current_snapshot=to_text)
except Exception as e:
logger.error(f"LLM summary generation failed for {uuid}: {e}")
return jsonify({'summary': None, 'error': str(e)}), 500
if not summary:
return jsonify({'summary': None, 'error': 'LLM returned empty summary'})
# Persist with cache key so subsequent requests (and next page load) are instant
try:
watch.save_llm_diff_summary(summary, cache_key=cache_key)
except Exception as e:
logger.warning(f"Could not cache llm summary for {uuid}: {e}")
return jsonify({'summary': summary, 'error': None, 'cached': False})
@diff_blueprint.route("/diff/<uuid_str:uuid>/extract", methods=['GET'])
@login_optionally_required
def diff_history_page_extract_GET(uuid):

View File

@@ -10,10 +10,24 @@ from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio.time_handler import is_within_schedule
from changedetectionio import worker_pool
from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates")
def _resolve_llm_intent_source(watch, datastore) -> str:
"""
Return the source of the effective LLM intent: 'watch', tag title, or ''.
Used in the edit form to show an "inherited from tag: X" hint.
"""
if (watch.get('llm_intent') or '').strip():
return 'watch'
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag and (tag.get('llm_intent') or '').strip():
return tag.get('title', 'tag')
return ''
def _watch_has_tag_options_set(watch):
"""This should be fixed better so that Tag is some proper Model, a tag is just a Watch also"""
for tag_uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
@@ -142,7 +156,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
for p in datastore.extra_browsers:
form.fetch_backend.choices.append(p)
form.fetch_backend.choices.append(("system", gettext('System settings default')))
form.fetch_backend.choices.append(("system", 'System settings default'))
# form.browser_steps[0] can be assumed that we 'goto url' first
@@ -150,7 +164,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead
del form.proxy
else:
form.proxy.choices = [('', gettext('Default'))]
form.proxy.choices = [('', 'Default')]
for p in datastore.proxy_list:
form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label'])))
@@ -301,7 +315,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
'extra_classes': ' '.join(c),
'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
'extra_processor_config': form.extra_tab_content(),
'extra_title': f" - {gettext('Edit')} - {watch.label}",
'extra_title': f" - Edit - {watch.label}",
'form': form,
'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False,
'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,
@@ -326,6 +340,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
for tag_uuid, tag in datastore.data['settings']['application']['tags'].items()
if tag_uuid not in watch.get('tags', []) and tag.matches_url(watch.get('url', ''))
},
# LLM intent context
'llm_configured': bool(_get_llm_config(datastore)),
'llm_intent_source': _resolve_llm_intent_source(watch, datastore),
}
included_content = None

View File

@@ -107,7 +107,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
current_diff_url=watch['url'],
current_version=timestamp,
extra_stylesheets=extra_stylesheets,
extra_title=f" - {gettext('Diff')} - {watch.label} @ {timestamp}",
extra_title=f" - Diff - {watch.label} @ {timestamp}",
highlight_ignored_line_numbers=ignored_line_numbers,
highlight_triggered_line_numbers=triggered_line_numbers,
highlight_blocked_line_numbers=blocked_line_numbers,

View File

@@ -127,6 +127,16 @@
<div class="tip">{{ _('Pro-tip: You can enable') }} <strong>{{ _('"share access when password is enabled"') }}</strong> {{ _('from settings.') }}
</div>
{%- endif -%}
{%- if llm_configured -%}
<div id="llm-diff-summary-area"{% if not llm_diff_summary %} data-pending="1"{% endif %}>
<span class="llm-diff-summary-label">&#x2728; {{ _('AI Change Summary') }}</span>
{%- if llm_diff_summary -%}
<p class="llm-diff-summary-text">{{ llm_diff_summary }}</p>
{%- else -%}
<p class="llm-diff-summary-text llm-diff-summary-loading">{{ _('Generating summary…') }}</p>
{%- endif -%}
</div>
{%- endif -%}
<div id="text-diff-heading-area" style="user-select: none;">
<div class="snapshot-age"><span>{{ from_version|format_timestamp_timeago }}</span>
{%- if note -%}<span class="note"><strong>{{ note }}</strong></span>{%- endif -%}
@@ -162,5 +172,32 @@
</script>
<script src="{{url_for('static_content', group='js', filename='diff-render.js')}}"></script>
{% if llm_configured %}
<script>
$(function () {
var $area = $('#llm-diff-summary-area');
if (!$area.length || !$area.data('pending')) return;
var fromVersion = $('#diff-from-version').val();
var toVersion = $('#diff-to-version').val();
var summaryUrl = "{{ url_for('ui.ui_diff.diff_llm_summary', uuid=uuid) }}";
$.getJSON(summaryUrl, { from_version: fromVersion, to_version: toVersion })
.done(function (data) {
if (data.summary) {
$area.find('.llm-diff-summary-text')
.removeClass('llm-diff-summary-loading')
.text(data.summary);
$area.removeAttr('data-pending');
} else {
$area.remove();
}
})
.fail(function () {
$area.remove();
});
});
</script>
{% endif %}
{% endblock %}

View File

@@ -321,6 +321,11 @@ Math: {{ 1 + 1 }}") }}
</div>
</div>
<div class="tab-pane-inner" id="filters-and-triggers">
{# ── AI Intent ─────────────────────────────────────────── #}
{% include "edit/include_llm_intent.html" %}
{# ── end AI Intent ──────────────────────────────────────── #}
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">{{ _('Activate preview') }}</span>
<div>
<div id="edit-text-filter">
@@ -374,7 +379,20 @@ Math: {{ 1 + 1 }}") }}
const preview_text_edit_filters_url="{{url_for('ui.ui_edit.watch_get_preview_rendered', uuid=uuid)}}";
</script>
<br>
{#<div id="text-preview-controls"><span id="text-preview-refresh" class="pure-button button-xsmall">Refresh</span></div>#}
{% if llm_configured %}
<div id="llm-preview-result" style="display:none; margin-bottom: 0.8em; padding: 0.8em 1.1em; border-radius: 4px; border-left: 4px solid #ccc; font-size: 0.9em;">
<div style="font-size:0.75em; text-transform:uppercase; letter-spacing:0.06em; opacity:0.55; margin-bottom:0.35em;">{{ _('AI Intent preview') }}</div>
<span class="llm-preview-verdict" style="font-weight: bold;"></span>
<div class="llm-preview-answer" style="margin-top: 0.5em; white-space: pre-wrap; line-height: 1.5; font-style: italic;"></div>
</div>
<style>
#llm-preview-result { transition: border-color 0.2s, background 0.2s; }
#llm-preview-result[data-found="1"] { border-color: #2ecc71; background: rgba(46,204,113,0.07); }
#llm-preview-result[data-found="1"] .llm-preview-verdict { color: #27ae60; }
#llm-preview-result[data-found="0"] { border-color: #aaa; background: rgba(0,0,0,0.03); }
#llm-preview-result[data-found="0"] .llm-preview-verdict { color: #888; }
</style>
{% endif %}
<div class="minitabs-wrapper">
<div class="minitabs-content">
<div id="text-preview-inner" class="monospace-preview">
@@ -484,6 +502,16 @@ Math: {{ 1 + 1 }}") }}
<td>{{ _('Server type reply') }}</td>
<td>{{ watch.get('remote_server_reply') }}</td>
</tr>
{% if settings_application.get('llm', {}).get('model') %}
<tr>
<td>{{ _('AI tokens (last check)') }}</td>
<td>{{ "{:,}".format(watch.get('llm_last_tokens_used') or 0) }}</td>
</tr>
<tr>
<td>{{ _('AI tokens (total)') }}</td>
<td>{{ "{:,}".format(watch.get('llm_tokens_used_cumulative') or 0) }}</td>
</tr>
{% endif %}
</tbody>
</table>

View File

@@ -245,10 +245,10 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
<td class="inline checkbox-uuid" ><div><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span class="counter-i">{{ loop.index+pagination.skip }}</span></div></td>
<td class="inline watch-controls">
<div>
<a class="ajax-op state-off pause-toggle" data-op="pause" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="{{ _('Pause checks') }}" title="{{ _('Pause checks') }}" class="icon icon-pause" ></a>
<a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="{{ _('UnPause checks') }}" title="{{ _('UnPause checks') }}" class="icon icon-unpause" ></a>
<a class="ajax-op state-off mute-toggle" data-op="mute" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{{ _('Mute notification') }}" title="{{ _('Mute notification') }}" class="icon icon-mute" ></a>
<a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{{ _('UnMute notification') }}" title="{{ _('UnMute notification') }}" class="icon icon-mute" ></a>
<a class="ajax-op state-off pause-toggle" data-op="pause" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a>
<a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a>
<a class="ajax-op state-off mute-toggle" data-op="mute" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notification" title="Mute notification" class="icon icon-mute" ></a>
<a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="UnMute notification" title="UnMute notification" class="icon icon-mute" ></a>
</div>
</td>
@@ -292,7 +292,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
{%- endfor -%}
</div>
<div class="status-icons">
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="{{ _('Create a link to share watch config with others') }}" ></a>
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a>
{%- set effective_fetcher = watch.get_fetch_backend if watch.get_fetch_backend != "system" else system_default_fetcher -%}
{%- if effective_fetcher and ("html_webdriver" in effective_fetcher or "html_" in effective_fetcher or "extra_browser_" in effective_fetcher) -%}
{{ effective_fetcher|fetcher_status_icons }}

View File

@@ -1,5 +1,4 @@
from json_logic.builtins import BUILTINS
from flask_babel import lazy_gettext as _l
from .exceptions import EmptyConditionRuleRowNotUsable
from .pluggy_interface import plugin_manager # Import the pluggy plugin manager
@@ -7,19 +6,19 @@ from . import default_plugin
from loguru import logger
# List of all supported JSON Logic operators
operator_choices = [
(None, _l("Choose one - Operator")),
(">", _l("Greater Than")),
("<", _l("Less Than")),
(">=", _l("Greater Than or Equal To")),
("<=", _l("Less Than or Equal To")),
("==", _l("Equals")),
("!=", _l("Not Equals")),
("in", _l("Contains")),
(None, "Choose one - Operator"),
(">", "Greater Than"),
("<", "Less Than"),
(">=", "Greater Than or Equal To"),
("<=", "Less Than or Equal To"),
("==", "Equals"),
("!=", "Not Equals"),
("in", "Contains"),
]
# Fields available in the rules
field_choices = [
(None, _l("Choose one - Field")),
(None, "Choose one - Field"),
]
# The data we will feed the JSON Rules to see if it passes the test/conditions or not

View File

@@ -3,7 +3,6 @@ import re
import pluggy
from price_parser import Price
from loguru import logger
from flask_babel import lazy_gettext as _l
hookimpl = pluggy.HookimplMarker("changedetectionio_conditions")
@@ -48,22 +47,22 @@ def register_operators():
@hookimpl
def register_operator_choices():
return [
("!in", _l("Does NOT Contain")),
("starts_with", _l("Text Starts With")),
("ends_with", _l("Text Ends With")),
("length_min", _l("Length minimum")),
("length_max", _l("Length maximum")),
("contains_regex", _l("Text Matches Regex")),
("!contains_regex", _l("Text Does NOT Match Regex")),
("!in", "Does NOT Contain"),
("starts_with", "Text Starts With"),
("ends_with", "Text Ends With"),
("length_min", "Length minimum"),
("length_max", "Length maximum"),
("contains_regex", "Text Matches Regex"),
("!contains_regex", "Text Does NOT Match Regex"),
]
@hookimpl
def register_field_choices():
return [
("extracted_number", _l("Extracted number after 'Filters & Triggers'")),
("extracted_number", "Extracted number after 'Filters & Triggers'"),
# ("meta_description", "Meta Description"),
# ("meta_keywords", "Meta Keywords"),
("page_filtered_text", _l("Page text after 'Filters & Triggers'")),
("page_filtered_text", "Page text after 'Filters & Triggers'"),
#("page_title", "Page <title>"), # actual page title <title>
]

View File

@@ -1,7 +1,6 @@
# Condition Rule Form (for each rule row)
from wtforms import Form, SelectField, StringField, validators
from wtforms import validators
from flask_babel import lazy_gettext as _l
class ConditionFormRow(Form):
@@ -9,18 +8,18 @@ class ConditionFormRow(Form):
from changedetectionio.conditions import plugin_manager
from changedetectionio.conditions import operator_choices, field_choices
field = SelectField(
_l("Field"),
"Field",
choices=field_choices,
validators=[validators.Optional()]
)
operator = SelectField(
_l("Operator"),
"Operator",
choices=operator_choices,
validators=[validators.Optional()]
)
value = StringField(_l("Value"), validators=[validators.Optional()], render_kw={"placeholder": _l("A value")})
value = StringField("Value", validators=[validators.Optional()], render_kw={"placeholder": "A value"})
def validate(self, extra_validators=None):
# First, run the default validators
@@ -31,15 +30,15 @@ class ConditionFormRow(Form):
# If any of the operator/field/value is set, then they must be all set
if any(value not in ("", False, "None", None) for value in [self.operator.data, self.field.data, self.value.data]):
if not self.operator.data or self.operator.data == 'None':
self.operator.errors.append(_l("Operator is required."))
self.operator.errors.append("Operator is required.")
return False
if not self.field.data or self.field.data == 'None':
self.field.errors.append(_l("Field is required."))
self.field.errors.append("Field is required.")
return False
if not self.value.data:
self.value.errors.append(_l("Value is required."))
self.value.errors.append("Value is required.")
return False
return True # Only return True if all conditions pass

View File

@@ -4,7 +4,6 @@ Provides metrics for measuring text similarity between snapshots.
"""
import pluggy
from loguru import logger
from flask_babel import gettext as _, lazy_gettext as _l
LEVENSHTEIN_MAX_LEN_FOR_EDIT_STATS=100000
@@ -54,8 +53,8 @@ def register_operator_choices():
@conditions_hookimpl
def register_field_choices():
return [
("levenshtein_ratio", _l("Levenshtein - Text similarity ratio")),
("levenshtein_distance", _l("Levenshtein - Text change distance")),
("levenshtein_ratio", "Levenshtein - Text similarity ratio"),
("levenshtein_distance", "Levenshtein - Text change distance"),
]
@conditions_hookimpl
@@ -78,7 +77,7 @@ def ui_edit_stats_extras(watch):
"""Add Levenshtein stats to the UI using the global plugin system"""
"""Generate the HTML for Levenshtein stats - shared by both plugin systems"""
if len(watch.history.keys()) < 2:
return f"<p>{_('Not enough history to calculate Levenshtein metrics')}</p>"
return "<p>Not enough history to calculate Levenshtein metrics</p>"
# Protection against the algorithm getting stuck on huge documents
@@ -88,37 +87,37 @@ def ui_edit_stats_extras(watch):
for idx in (-1, -2)
if len(k) >= abs(idx)
):
return f"<p>{_('Snapshot too large for edit statistics, skipping.')}</p>"
return "<p>Snapshot too large for edit statistics, skipping.</p>"
try:
lev_data = levenshtein_ratio_recent_history(watch)
if not lev_data or not isinstance(lev_data, dict):
return f"<p>{_('Unable to calculate Levenshtein metrics')}</p>"
return "<p>Unable to calculate Levenshtein metrics</p>"
html = f"""
<div class="levenshtein-stats">
<h4>{_('Levenshtein Text Similarity Details')}</h4>
<h4>Levenshtein Text Similarity Details</h4>
<table class="pure-table">
<tbody>
<tr>
<td>{_('Raw distance (edits needed)')}</td>
<td>Raw distance (edits needed)</td>
<td>{lev_data['distance']}</td>
</tr>
<tr>
<td>{_('Similarity ratio')}</td>
<td>Similarity ratio</td>
<td>{lev_data['ratio']:.4f}</td>
</tr>
<tr>
<td>{_('Percent similar')}</td>
<td>Percent similar</td>
<td>{lev_data['percent_similar']}%</td>
</tr>
</tbody>
</table>
<p style="font-size: 80%;">{_('Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one into the other.')}</p>
<p style="font-size: 80%;">Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one into the other.</p>
</div>
"""
return html
except Exception as e:
logger.error(f"Error generating Levenshtein UI extras: {str(e)}")
return f"<p>{_('Error calculating Levenshtein metrics')}</p>"
return "<p>Error calculating Levenshtein metrics</p>"

View File

@@ -4,7 +4,6 @@ Provides word count metrics for snapshot content.
"""
import pluggy
from loguru import logger
from flask_babel import gettext as _, lazy_gettext as _l
# Support both plugin systems
conditions_hookimpl = pluggy.HookimplMarker("changedetectionio_conditions")
@@ -41,7 +40,7 @@ def register_operator_choices():
def register_field_choices():
# Add a field that will be available in conditions
return [
("word_count", _l("Word count of content")),
("word_count", "Word count of content"),
]
@conditions_hookimpl
@@ -62,16 +61,16 @@ def _generate_stats_html(watch):
html = f"""
<div class="word-count-stats">
<h4>{_('Content Analysis')}</h4>
<h4>Content Analysis</h4>
<table class="pure-table">
<tbody>
<tr>
<td>{_('Word count (latest snapshot)')}</td>
<td>Word count (latest snapshot)</td>
<td>{word_count}</td>
</tr>
</tbody>
</table>
<p style="font-size: 80%;">{_('Word count is a simple measure of content length, calculated by splitting text on whitespace.')}</p>
<p style="font-size: 80%;">Word count is a simple measure of content length, calculated by splitting text on whitespace.</p>
</div>
"""
return html

View File

@@ -1,4 +1,3 @@
from flask_babel import lazy_gettext as _l
from loguru import logger
from urllib.parse import urljoin, urlparse
import hashlib
@@ -14,7 +13,7 @@ from changedetectionio.validate_url import is_private_hostname
# "html_requests" is listed as the default fetcher in store.py!
class fetcher(Fetcher):
fetcher_description = _l("Basic fast Plaintext/HTTP Client")
fetcher_description = "Basic fast Plaintext/HTTP Client"
def __init__(self, proxy_override=None, custom_browser_connection_url=None, **kwargs):
super().__init__(**kwargs)

View File

@@ -771,16 +771,16 @@ class SingleBrowserStep(Form):
operation = SelectField(_l('Operation'), [validators.Optional()], choices=browser_step_ui_config.keys())
# maybe better to set some <script>var..
selector = StringField(_l('Selector'), [validators.Optional()], render_kw={"placeholder": _l("CSS or xPath selector")})
optional_value = StringField(_l('value'), [validators.Optional()], render_kw={"placeholder": _l("Value")})
selector = StringField(_l('Selector'), [validators.Optional()], render_kw={"placeholder": "CSS or xPath selector"})
optional_value = StringField(_l('value'), [validators.Optional()], render_kw={"placeholder": "Value"})
# @todo move to JS? ajax fetch new field?
# remove_button = SubmitField(_l('-'), render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Remove'})
# add_button = SubmitField(_l('+'), render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Add new step after'})
class processor_text_json_diff_form(commonSettingsForm):
url = StringField(_l('Web Page URL'), validators=[validateURL()])
tags = StringTagUUID(_l('Group Tag'), [validators.Optional()], default='')
url = StringField('Web Page URL', validators=[validateURL()])
tags = StringTagUUID('Group Tag', [validators.Optional()], default='')
time_between_check = EnhancedFormField(
TimeBetweenCheckForm,
@@ -794,6 +794,13 @@ class processor_text_json_diff_form(commonSettingsForm):
time_between_check_use_default = BooleanField(_l('Use global settings for time between check and scheduler.'), default=False)
llm_intent = TextAreaField(_l('AI Change Intent'), validators=[validators.Optional(), validators.Length(max=2000)],
render_kw={"rows": "3", "placeholder": "e.g. Alert me when the price drops below $300"})
llm_change_summary = TextAreaField(_l('AI Change Summary'), validators=[validators.Optional(), validators.Length(max=2000)],
render_kw={"rows": "3", "placeholder": "e.g. List what was added or removed. Translate to English."},
default='')
include_filters = StringListField(_l('CSS/JSONPath/JQ/XPath Filters'), [ValidateCSSJSONXPATHInput()], default='')
subtractive_selectors = StringListField(_l('Remove elements'), [ValidateCSSJSONXPATHInput(allow_json=False)])
@@ -918,7 +925,7 @@ class processor_text_json_diff_form(commonSettingsForm):
class SingleExtraProxy(Form):
# maybe better to set some <script>var..
proxy_name = StringField(_l('Name'), [validators.Optional()], render_kw={"placeholder": _l("Name")})
proxy_name = StringField(_l('Name'), [validators.Optional()], render_kw={"placeholder": "Name"})
proxy_url = StringField(_l('Proxy URL'), [
validators.Optional(),
ValidateStartsWithRegex(
@@ -930,7 +937,7 @@ class SingleExtraProxy(Form):
], render_kw={"placeholder": "socks5:// or regular proxy http://user:pass@...:3128", "size":50})
class SingleExtraBrowser(Form):
browser_name = StringField(_l('Name'), [validators.Optional()], render_kw={"placeholder": _l("Name")})
browser_name = StringField(_l('Name'), [validators.Optional()], render_kw={"placeholder": "Name"})
browser_connection_url = StringField(_l('Browser connection URL'), [
validators.Optional(),
ValidateStartsWithRegex(
@@ -999,7 +1006,7 @@ class globalSettingsApplicationForm(commonSettingsForm):
# Screenshot comparison settings
min_change_percentage = FloatField(
_l('Screenshot: Minimum Change Percentage'),
'Screenshot: Minimum Change Percentage',
validators=[
validators.Optional(),
validators.NumberRange(min=0.0, max=100.0, message=_l('Must be between 0 and 100'))
@@ -1039,6 +1046,61 @@ class globalSettingsApplicationForm(commonSettingsForm):
ui = FormField(globalSettingsApplicationUIForm)
class globalSettingsLLMForm(Form):
"""
LLM / AI provider settings — stored under datastore['settings']['application']['llm'].
Uses litellm under the hood, so the model string encodes both the provider and model.
No separate provider dropdown needed — litellm routes automatically:
gpt-4o-mini → OpenAI
claude-3-5-haiku-20251001 → Anthropic
ollama/llama3.2 → Ollama (local)
openrouter/google/gemma-3-12b-it:free → OpenRouter (free tier)
gemini/gemini-2.0-flash → Google Gemini
azure/gpt-4o → Azure OpenAI
"""
llm_model = StringField(
_l('Model'),
validators=[validators.Optional()],
render_kw={"placeholder": "gpt-4o-mini", "style": "width: 24em;"},
)
llm_api_key = StringField(
_l('API Key'),
validators=[validators.Optional()],
render_kw={
"placeholder": _l('Leave blank to use LITELLM_API_KEY env var'),
"autocomplete": "off",
"style": "width: 24em;",
},
)
llm_api_base = StringField(
_l('API Base URL'),
validators=[validators.Optional()],
render_kw={
"placeholder": "http://localhost:11434 (Ollama / custom endpoints only)",
"style": "width: 24em;",
},
)
llm_max_tokens_per_check = IntegerField(
_l('Max tokens per check'),
validators=[validators.Optional(), validators.NumberRange(min=0)],
default=0,
render_kw={
"placeholder": "0 = unlimited",
"style": "width: 8em;",
},
)
llm_max_tokens_cumulative = IntegerField(
_l('Max cumulative tokens (per watch)'),
validators=[validators.Optional(), validators.NumberRange(min=0)],
default=0,
render_kw={
"placeholder": "0 = unlimited",
"style": "width: 8em;",
},
)
class globalSettingsForm(Form):
# Define these as FormFields/"sub forms", this way it matches the JSON storage
# datastore.data['settings']['application']..
@@ -1051,6 +1113,7 @@ class globalSettingsForm(Form):
requests = FormField(globalSettingsRequestForm)
application = FormField(globalSettingsApplicationForm)
llm = FormField(globalSettingsLLMForm)
save_button = SubmitField(_l('Save'), render_kw={"class": "pure-button pure-button-primary"})

View File

@@ -0,0 +1 @@
# LLM intent-based change evaluation

View File

@@ -0,0 +1,52 @@
"""
BM25-based relevance trimming for large snapshot text.
When a snapshot is large and no CSS pre-filter has narrowed it down,
we use BM25 to select the lines most relevant to the user's intent
before sending to the LLM. This keeps the context focused without
an arbitrary char truncation.
Pure functions — no side effects, fully testable.
"""
MAX_CONTEXT_CHARS = 15_000
def trim_to_relevant(text: str, query: str, max_chars: int = MAX_CONTEXT_CHARS) -> str:
"""
Return the lines from `text` most relevant to `query` up to `max_chars`.
If text fits within budget, return it unchanged.
Falls back to head-truncation if rank_bm25 is unavailable.
"""
if not text or not query:
return text or ''
if len(text) <= max_chars:
return text
lines = [l for l in text.splitlines() if l.strip()]
if not lines:
return text[:max_chars]
try:
from rank_bm25 import BM25Okapi
except ImportError:
# rank-bm25 not installed — fall back to simple head truncation
return text[:max_chars]
tokenized = [line.lower().split() for line in lines]
bm25 = BM25Okapi(tokenized)
scores = bm25.get_scores(query.lower().split())
ranked = sorted(enumerate(zip(scores, lines)), key=lambda x: x[1][0], reverse=True)
selected_indices, total = [], 0
for idx, (_score, line) in ranked:
if total + len(line) + 1 > max_chars:
break
selected_indices.append(idx)
total += len(line) + 1
# Re-order selected lines to preserve original document order
ordered = [lines[i] for i in sorted(selected_indices)]
return '\n'.join(ordered)

View File

@@ -0,0 +1,47 @@
"""
Thin wrapper around litellm.completion.
Keeps litellm import isolated so the rest of the codebase doesn't depend on it directly,
and makes the call easy to mock in tests.
"""
from loguru import logger
# Output token cap for all LLM calls — our JSON response is always <50 tokens,
# so 200 is a generous hard cap that prevents runaway per-call cost.
_MAX_COMPLETION_TOKENS = 200
def completion(model: str, messages: list, api_key: str = None,
api_base: str = None, timeout: int = 30,
max_tokens: int = None) -> tuple[str, int]:
"""
Call the LLM and return (response_text, total_tokens_used).
total_tokens_used is 0 if the provider doesn't return usage data.
Raises on network/auth errors — callers handle gracefully.
"""
try:
import litellm
except ImportError:
raise RuntimeError("litellm is not installed. Add it to requirements.txt.")
kwargs = {
'model': model,
'messages': messages,
'timeout': timeout,
'temperature': 0,
'max_tokens': max_tokens if max_tokens is not None else _MAX_COMPLETION_TOKENS,
}
if api_key:
kwargs['api_key'] = api_key
if api_base:
kwargs['api_base'] = api_base
try:
response = litellm.completion(**kwargs)
text = response.choices[0].message.content
usage = getattr(response, 'usage', None)
total_tokens = int(getattr(usage, 'total_tokens', 0) or 0) if usage else 0
return text, total_tokens
except Exception as e:
logger.warning(f"LLM call failed: {e}")
raise

View File

@@ -0,0 +1,385 @@
"""
LLM evaluation orchestration.
Two public entry points:
- run_setup(watch, datastore) — one-time: decide if pre-filter needed
- evaluate_change(watch, datastore, diff, current_snapshot) — per-change evaluation
Intent resolution: watch.llm_intent → first tag with llm_intent → None (no evaluation)
Cache: each (intent, diff) pair is evaluated exactly once, result stored in watch.
Environment variable overrides (take priority over datastore settings):
LLM_MODEL — model string (e.g. "gpt-4o-mini", "ollama/llama3.2")
LLM_API_KEY — API key for cloud providers
LLM_API_BASE — base URL for local/custom endpoints (e.g. http://localhost:11434)
"""
import hashlib
import os
from loguru import logger
from . import client as llm_client
from .prompt_builder import (
build_change_summary_prompt, build_change_summary_system_prompt,
build_eval_prompt, build_eval_system_prompt,
build_preview_prompt, build_preview_system_prompt,
build_setup_prompt, build_setup_system_prompt,
)
from .response_parser import parse_eval_response, parse_preview_response, parse_setup_response
# AI Change Summary can produce longer output than eval responses
_MAX_SUMMARY_TOKENS = 500
# Default prompt used when the user hasn't configured llm_change_summary
DEFAULT_CHANGE_SUMMARY_PROMPT = "Briefly describe in plain English what changed — what was added, removed, or modified."
# ---------------------------------------------------------------------------
# Intent resolution
# ---------------------------------------------------------------------------
def resolve_llm_field(watch, datastore, field: str) -> tuple[str, str]:
"""
Generic cascade resolver for any LLM per-watch field.
Returns (value, source) where source is 'watch' or tag title.
Returns ('', '') if not set anywhere.
"""
value = (watch.get(field) or '').strip()
if value:
return value, 'watch'
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag:
tag_value = (tag.get(field) or '').strip()
if tag_value:
return tag_value, tag.get('title', 'tag')
return '', ''
def resolve_intent(watch, datastore) -> tuple[str, str]:
"""
Return (intent, source) where source is 'watch' or tag title.
Returns ('', '') if no intent is configured anywhere.
"""
intent = (watch.get('llm_intent') or '').strip()
if intent:
return intent, 'watch'
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag:
tag_intent = (tag.get('llm_intent') or '').strip()
if tag_intent:
return tag_intent, tag.get('title', 'tag')
return '', ''
# ---------------------------------------------------------------------------
# LLM config helper
# ---------------------------------------------------------------------------
def get_llm_config(datastore) -> dict | None:
"""
Return LLM config dict or None if not configured.
Resolution order (first non-empty model wins):
1. Environment variables: LLM_MODEL, LLM_API_KEY, LLM_API_BASE
2. Datastore settings (set via UI)
"""
# 1. Environment variable override
env_model = os.getenv('LLM_MODEL', '').strip()
if env_model:
return {
'model': env_model,
'api_key': os.getenv('LLM_API_KEY', '').strip(),
'api_base': os.getenv('LLM_API_BASE', '').strip(),
}
# 2. Datastore settings
cfg = datastore.data['settings']['application'].get('llm') or {}
if not cfg.get('model'):
return None
return cfg
def llm_configured_via_env() -> bool:
"""True when LLM config comes from environment variables, not the UI."""
return bool(os.getenv('LLM_MODEL', '').strip())
# ---------------------------------------------------------------------------
# One-time setup: derive pre-filter
# ---------------------------------------------------------------------------
def _check_token_budget(watch, cfg, tokens_this_call: int = 0) -> bool:
"""
Check token budget limits. Returns True if within budget, False if exceeded.
Also accumulates tokens_this_call into watch['llm_tokens_used_cumulative'].
"""
if tokens_this_call > 0:
current = watch.get('llm_tokens_used_cumulative') or 0
watch['llm_tokens_used_cumulative'] = current + tokens_this_call
max_per_check = int(cfg.get('max_tokens_per_check') or 0)
max_cumulative = int(cfg.get('max_tokens_cumulative') or 0)
if max_per_check and tokens_this_call > max_per_check:
logger.warning(
f"LLM token budget exceeded for {watch.get('uuid')}: "
f"{tokens_this_call} tokens > per-check limit {max_per_check}"
)
return False
if max_cumulative:
total = watch.get('llm_tokens_used_cumulative') or 0
if total > max_cumulative:
logger.warning(
f"LLM cumulative token budget exceeded for {watch.get('uuid')}: "
f"{total} tokens > limit {max_cumulative}"
)
return False
return True
def run_setup(watch, datastore, snapshot_text: str) -> None:
"""
Ask the LLM whether a CSS pre-filter would improve precision for this intent.
Stores result in watch['llm_prefilter'] (str selector or None).
Called once when intent is first set, and again if pre-filter returns zero matches.
"""
cfg = get_llm_config(datastore)
if not cfg:
return
intent, _ = resolve_intent(watch, datastore)
if not intent:
return
url = watch.get('url', '')
system_prompt = build_setup_system_prompt()
user_prompt = build_setup_prompt(intent, snapshot_text, url=url)
try:
raw, tokens = llm_client.completion(
model=cfg['model'],
messages=[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt},
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
)
_check_token_budget(watch, cfg, tokens)
result = parse_setup_response(raw)
watch['llm_prefilter'] = result['selector']
logger.debug(f"LLM setup for {watch.get('uuid')}: prefilter={result['selector']} reason={result['reason']}")
except Exception as e:
logger.warning(f"LLM setup call failed for {watch.get('uuid')}: {e}")
watch['llm_prefilter'] = None
# ---------------------------------------------------------------------------
# AI Change Summary — human-readable description of what changed
# ---------------------------------------------------------------------------
def get_effective_summary_prompt(watch, datastore) -> str:
"""Return the prompt that summarise_change will use — custom or the default fallback."""
prompt, _ = resolve_llm_field(watch, datastore, 'llm_change_summary')
return prompt or DEFAULT_CHANGE_SUMMARY_PROMPT
def compute_summary_cache_key(diff_text: str, prompt: str) -> str:
"""Stable 16-char hex key for a (diff, prompt) pair. Stored alongside the summary file."""
h = hashlib.md5()
h.update(diff_text.encode('utf-8', errors='replace'))
h.update(b'\x00')
h.update(prompt.encode('utf-8', errors='replace'))
return h.hexdigest()[:16]
def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') -> str:
"""
Generate a plain-language summary of the change using the watch's
llm_change_summary prompt (cascades from tag if not set on watch).
Returns the summary string, or '' on failure.
The result replaces {{ diff }} in notifications so the user gets a
readable description instead of raw +/- diff lines.
"""
cfg = get_llm_config(datastore)
if not cfg:
return ''
custom_prompt, _ = resolve_llm_field(watch, datastore, 'llm_change_summary')
if not custom_prompt:
custom_prompt = DEFAULT_CHANGE_SUMMARY_PROMPT
if not diff.strip():
return ''
url = watch.get('url', '')
title = watch.get('page_title') or watch.get('title') or ''
system_prompt = build_change_summary_system_prompt()
user_prompt = build_change_summary_prompt(
diff=diff,
custom_prompt=custom_prompt,
current_snapshot=current_snapshot,
url=url,
title=title,
)
try:
raw, tokens = llm_client.completion(
model=cfg['model'],
messages=[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt},
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=_MAX_SUMMARY_TOKENS,
)
summary = raw.strip()
_check_token_budget(watch, cfg, tokens)
watch['llm_last_tokens_used'] = (watch.get('llm_last_tokens_used') or 0) + tokens
logger.debug(
f"LLM change summary {watch.get('uuid')}: tokens={tokens} "
f"summary={summary[:80]}"
)
return summary
except Exception as e:
logger.warning(f"LLM change summary failed for {watch.get('uuid')}: {e}")
return ''
# ---------------------------------------------------------------------------
# Live-preview extraction (current content, no diff)
# ---------------------------------------------------------------------------
def preview_extract(watch, datastore, content: str) -> dict | None:
"""
For the live-preview endpoint: extract relevant information from the
*current* page content according to the watch's intent.
Unlike evaluate_change (which compares a diff), this asks the LLM to
directly answer the intent against the current snapshot — giving the user
immediate feedback like "30 articles listed" or "Price: $149, 25% off".
Returns {'found': bool, 'answer': str} or None if LLM not configured / no intent.
"""
cfg = get_llm_config(datastore)
if not cfg:
return None
intent, _ = resolve_intent(watch, datastore)
if not intent or not content.strip():
return None
url = watch.get('url', '')
title = watch.get('page_title') or watch.get('title') or ''
system_prompt = build_preview_system_prompt()
user_prompt = build_preview_prompt(intent, content, url=url, title=title)
try:
raw, tokens = llm_client.completion(
model=cfg['model'],
messages=[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt},
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
)
result = parse_preview_response(raw)
logger.debug(
f"LLM preview {watch.get('uuid')}: found={result['found']} "
f"tokens={tokens} answer={result['answer'][:80]}"
)
return result
except Exception as e:
logger.warning(f"LLM preview extraction failed for {watch.get('uuid')}: {e}")
return None
# ---------------------------------------------------------------------------
# Per-change evaluation
# ---------------------------------------------------------------------------
def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') -> dict | None:
"""
Evaluate whether `diff` matches the watch's intent.
Returns {'important': bool, 'summary': str} or None if LLM not configured / no intent.
Results are cached by (intent, diff) hash — each unique diff is evaluated exactly once.
"""
cfg = get_llm_config(datastore)
if not cfg:
return None
intent, source = resolve_intent(watch, datastore)
if not intent:
return None
if not diff or not diff.strip():
return {'important': False, 'summary': ''}
# Cache lookup — evaluations are deterministic once cached
cache_key = hashlib.sha256(f"{intent}||{diff}".encode()).hexdigest()
cache = watch.get('llm_evaluation_cache') or {}
if cache_key in cache:
logger.debug(f"LLM cache hit for {watch.get('uuid')} key={cache_key[:8]}")
return cache[cache_key]
# Check cumulative budget before making the call
if not _check_token_budget(watch, cfg):
# Already over budget — fail open (don't suppress notification)
return {'important': True, 'summary': ''}
url = watch.get('url', '')
title = watch.get('page_title') or watch.get('title') or ''
system_prompt = build_eval_system_prompt()
user_prompt = build_eval_prompt(
intent=intent,
diff=diff,
current_snapshot=current_snapshot,
url=url,
title=title,
)
try:
raw, tokens = llm_client.completion(
model=cfg['model'],
messages=[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt},
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
)
result = parse_eval_response(raw)
except Exception as e:
logger.warning(f"LLM evaluation failed for {watch.get('uuid')}: {e}")
# On failure: don't suppress the notification — pass through as important
watch['llm_last_tokens_used'] = 0
return {'important': True, 'summary': ''}
# Accumulate token usage and enforce per-check limit
_check_token_budget(watch, cfg, tokens)
watch['llm_last_tokens_used'] = tokens
# Store in cache
if 'llm_evaluation_cache' not in watch or watch['llm_evaluation_cache'] is None:
watch['llm_evaluation_cache'] = {}
watch['llm_evaluation_cache'][cache_key] = result
logger.debug(
f"LLM eval {watch.get('uuid')} (intent from {source}): "
f"important={result['important']} tokens={tokens} summary={result['summary'][:80]}"
)
return result

View File

@@ -0,0 +1,148 @@
"""
Prompt construction for LLM evaluation calls.
Pure functions — no side effects, fully testable.
"""
from .bm25_trim import trim_to_relevant
SNAPSHOT_CONTEXT_CHARS = 3_000 # current page state excerpt sent alongside the diff
def build_eval_prompt(intent: str, diff: str, current_snapshot: str = '',
url: str = '', title: str = '') -> str:
"""
Build the user message for a diff evaluation call.
The system prompt is kept separate (see build_eval_system_prompt).
"""
parts = []
if url:
parts.append(f"URL: {url}")
if title:
parts.append(f"Page title: {title}")
parts.append(f"Intent: {intent}")
if current_snapshot:
excerpt = trim_to_relevant(current_snapshot, intent, max_chars=SNAPSHOT_CONTEXT_CHARS)
if excerpt:
parts.append(f"\nCurrent page state (relevant excerpt):\n{excerpt}")
parts.append(f"\nWhat changed (diff):\n{diff}")
return '\n'.join(parts)
def build_eval_system_prompt() -> str:
return (
"You evaluate website changes for a monitoring tool.\n"
"Given an intent and a diff (added/removed lines), decide if the change matches the intent.\n\n"
"Respond with ONLY a JSON object — no markdown, no explanation outside it:\n"
'{"important": true/false, "summary": "one sentence describing the relevant change, or why it doesn\'t match"}\n\n'
"Rules:\n"
"- important=true only when the diff clearly matches the intent\n"
"- Empty, trivial, or cosmetic diffs (dates, counters, whitespace) → important=false\n"
"- Use OR logic when intent lists multiple triggers\n"
"- Summary must be in the same language as the intent\n"
"- If important=false, summary briefly explains why it doesn't match"
)
def build_preview_prompt(intent: str, content: str, url: str = '', title: str = '') -> str:
"""
Build the user message for a live-preview extraction call.
Unlike build_eval_prompt (which analyses a diff), this asks the LLM to
extract relevant information from the *current* page content — giving the
user a direct answer to their intent so they can verify it makes sense
before saving.
"""
parts = []
if url:
parts.append(f"URL: {url}")
if title:
parts.append(f"Page title: {title}")
parts.append(f"Intent / question: {intent}")
parts.append(f"\nPage content:\n{content[:6_000]}")
return '\n'.join(parts)
def build_preview_system_prompt() -> str:
return (
"You are a web page content analyzer for a website monitoring tool.\n"
"Given the user's intent or question and the current page content, "
"extract and directly answer what the intent is looking for.\n\n"
"Respond with ONLY a JSON object — no markdown, no explanation outside it:\n"
'{"found": true/false, "answer": "concise direct answer or extraction"}\n\n'
"Rules:\n"
"- found=true when the page contains something relevant to the intent\n"
"- answer must directly address the intent (e.g. for 'how many articles?''30 articles listed')\n"
"- answer must be in the same language as the intent\n"
"- Keep answer brief — one sentence maximum"
)
def build_change_summary_prompt(diff: str, custom_prompt: str,
current_snapshot: str = '', url: str = '', title: str = '') -> str:
"""
Build the user message for an AI Change Summary call.
The user supplies their own instructions (custom_prompt); this wraps them
with the diff and optional page context.
"""
parts = []
if url:
parts.append(f"URL: {url}")
if title:
parts.append(f"Page title: {title}")
parts.append(f"Instructions: {custom_prompt}")
if current_snapshot:
excerpt = trim_to_relevant(current_snapshot, custom_prompt, max_chars=2_000)
if excerpt:
parts.append(f"\nCurrent page (excerpt):\n{excerpt}")
parts.append(f"\nWhat changed (diff):\n{diff}")
return '\n'.join(parts)
def build_change_summary_system_prompt() -> str:
return (
"You summarise website changes for a monitoring notification.\n"
"Given a diff of what changed and the user's formatting instructions, "
"produce a concise plain-language description of the change.\n"
"Follow the user's instructions exactly for format, language, and length.\n"
"Respond with ONLY the summary text — no JSON, no markdown code fences, "
"no preamble. Just the description."
)
def build_setup_prompt(intent: str, snapshot_text: str, url: str = '') -> str:
"""
Build the prompt for the one-time setup call that decides whether
a CSS pre-filter would improve evaluation precision.
"""
excerpt = trim_to_relevant(snapshot_text, intent, max_chars=4_000)
parts = []
if url:
parts.append(f"URL: {url}")
parts.append(f"Intent: {intent}")
parts.append(f"\nPage content excerpt:\n{excerpt}")
return '\n'.join(parts)
def build_setup_system_prompt() -> str:
return (
"You help configure a website change monitor.\n"
"Given a monitoring intent and a sample of the page content, decide if a CSS pre-filter "
"would improve evaluation precision by scoping the content to a specific structural section.\n\n"
"Respond with ONLY a JSON object:\n"
'{"needs_prefilter": true/false, "selector": "CSS selector or null", "reason": "one sentence"}\n\n'
"Rules:\n"
"- Only recommend a pre-filter when the intent references a specific structural section "
"(e.g. 'footer', 'sidebar', 'nav', 'header', 'main', 'article') OR the page clearly "
"has high-noise sections unrelated to the intent\n"
"- Use ONLY semantic element selectors: footer, nav, header, main, article, aside, "
"or attribute-based like [id*='price'], [class*='sidebar'] — NEVER positional selectors "
"like div:nth-child(3) or //*[2]\n"
"- Default to needs_prefilter=false — most intents don't need one\n"
"- selector must be null when needs_prefilter=false"
)

View File

@@ -0,0 +1,84 @@
"""
Parse and validate LLM JSON responses.
Pure functions — no side effects, fully testable.
LLMs occasionally return JSON wrapped in markdown fences or with trailing
text. This module handles those cases gracefully.
"""
import json
import re
# Positional selectors are fragile — reject them even if the LLM generates them
_POSITIONAL_SELECTOR_RE = re.compile(
r'nth-child|nth-of-type|:eq\(|\[\d+\]|\/\/\*\[\d',
re.IGNORECASE
)
def _extract_json(raw: str) -> str:
"""Strip markdown fences and extract the first JSON object."""
raw = raw.strip()
# Remove ```json ... ``` or ``` ... ``` fences
raw = re.sub(r'^```(?:json)?\s*', '', raw, flags=re.MULTILINE)
raw = re.sub(r'\s*```$', '', raw, flags=re.MULTILINE)
# Find the first { ... } block
match = re.search(r'\{.*\}', raw, re.DOTALL)
return match.group(0) if match else raw
def parse_eval_response(raw: str) -> dict:
"""
Parse a diff evaluation response.
Returns {'important': bool, 'summary': str}.
Falls back to important=False on any parse error.
"""
try:
data = json.loads(_extract_json(raw))
return {
'important': bool(data.get('important', False)),
'summary': str(data.get('summary', '')).strip(),
}
except (json.JSONDecodeError, AttributeError):
return {'important': False, 'summary': ''}
def parse_preview_response(raw: str) -> dict:
"""
Parse a live-preview extraction response.
Returns {'found': bool, 'answer': str}.
Falls back to found=False on any parse error.
"""
try:
data = json.loads(_extract_json(raw))
return {
'found': bool(data.get('found', False)),
'answer': str(data.get('answer', '')).strip(),
}
except (json.JSONDecodeError, AttributeError):
return {'found': False, 'answer': ''}
def parse_setup_response(raw: str) -> dict:
"""
Parse a setup/pre-filter decision response.
Returns {'needs_prefilter': bool, 'selector': str|None, 'reason': str}.
Rejects positional selectors even if the LLM generates them.
"""
try:
data = json.loads(_extract_json(raw))
needs = bool(data.get('needs_prefilter', False))
selector = data.get('selector') or None
# Sanitise: reject positional selectors
if selector and _POSITIONAL_SELECTOR_RE.search(selector):
selector = None
needs = False
return {
'needs_prefilter': needs,
'selector': selector if needs else None,
'reason': str(data.get('reason', '')).strip(),
}
except (json.JSONDecodeError, AttributeError):
return {'needs_prefilter': False, 'selector': None, 'reason': ''}

View File

@@ -1001,6 +1001,38 @@ class model(EntityPersistenceMixin, watch_base):
return False
def get_last_llm_diff_summary(self, cache_key: str = None) -> str:
"""Return the cached AI Change Summary, or '' if absent or stale.
If *cache_key* is provided the stored key is compared first;
a mismatch (different diff or prompt) returns '' so the caller
knows it must regenerate rather than serving a stale summary.
"""
fname = os.path.join(self.data_dir, 'last-llm-diff-summary.txt')
if not os.path.isfile(fname):
return ''
if cache_key is not None:
key_fname = os.path.join(self.data_dir, 'last-llm-diff-summary.key')
stored_key = ''
if os.path.isfile(key_fname):
with open(key_fname, 'r', encoding='utf-8') as f:
stored_key = f.read().strip()
if stored_key != cache_key:
return ''
with open(fname, 'r', encoding='utf-8') as f:
return f.read().strip()
def save_llm_diff_summary(self, summary: str, cache_key: str = None):
"""Persist the AI Change Summary and its cache key so stale entries are detectable."""
self.ensure_data_dir_exists()
fname = os.path.join(self.data_dir, 'last-llm-diff-summary.txt')
with open(fname, 'w', encoding='utf-8') as f:
f.write(summary)
if cache_key is not None:
key_fname = os.path.join(self.data_dir, 'last-llm-diff-summary.key')
with open(key_fname, 'w', encoding='utf-8') as f:
f.write(cache_key)
def pause(self):
self['paused'] = True

View File

@@ -188,6 +188,11 @@ class watch_base(dict):
'date_created': None,
'extract_lines_containing': [], # Keep only lines containing these substrings (plain text, case-insensitive)
'extract_text': [], # Extract text by regex after filters
# LLM intent-based evaluation
'llm_intent': '', # Plain-English description of what the user cares about (change filter)
'llm_change_summary': '', # Prompt for AI Change Summary — replaces {{ diff }} in notifications
'llm_prefilter': None, # CSS selector derived at setup time (semantic only, e.g. "footer")
'llm_evaluation_cache': {}, # {sha256(intent+diff): {important, summary}} - evaluated once, cached
'fetch_backend': 'system', # plaintext, playwright etc
'fetch_time': 0.0,
'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),

View File

@@ -48,8 +48,9 @@ To verify this works:
"""
import json
import os
import re
from urllib.parse import unquote_plus
from urllib.parse import unquote_plus, urlparse
import requests
from apprise import plugins
@@ -59,6 +60,8 @@ from apprise.utils.logic import dict_full_update
from loguru import logger
from requests.structures import CaseInsensitiveDict
from changedetectionio.validate_url import is_private_hostname
SUPPORTED_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head"}
@@ -195,6 +198,15 @@ def apprise_http_custom_handler(
url = re.sub(rf"^{schema}", "https" if schema.endswith("s") else "http", parsed_url.get("url"))
# SSRF protection — block private/loopback addresses unless explicitly allowed
if not os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', '').lower() in ('true', '1', 'yes'):
hostname = urlparse(url).hostname or ''
if hostname and is_private_hostname(hostname):
raise ValueError(
f"Notification target '{hostname}' is a private/reserved address. "
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
)
response = requests.request(
method=method,
url=url,

View File

@@ -364,6 +364,20 @@ def process_notification(n_object: NotificationContextData, datastore):
)
)
# {{ raw_diff }} always holds the actual diff regardless of AI Change Summary
n_object['raw_diff'] = n_object.get('diff', '')
# AI Change Summary: when configured, {{ diff }} renders the AI summary instead of raw diff
_llm_change_summary = (n_object.get('_llm_change_summary') or '').strip()
if _llm_change_summary:
n_object['diff'] = _llm_change_summary
# Lazily populate llm_summary / llm_intent if used in notification template
scan_text = n_object.get('notification_body', '') + n_object.get('notification_title', '')
if 'llm_summary' in scan_text or 'llm_intent' in scan_text or 'raw_diff' in scan_text:
n_object['llm_summary'] = _llm_change_summary or (n_object.get('_llm_result') or {}).get('summary', '')
n_object['llm_intent'] = n_object.get('_llm_intent', '')
with (apprise.LogCapture(level=apprise.logging.DEBUG) as logs):
for url in n_object['notification_urls']:

View File

@@ -194,6 +194,8 @@ class NotificationContextData(dict):
'timestamp_from': None,
'timestamp_to': None,
'triggered_text': None,
'llm_summary': None, # AI plain-English summary of what changed (requires AI intent to be configured)
'llm_intent': None, # The intent that was evaluated (watch-level or inherited from tag)
'uuid': 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX', # Converted to 'watch_uuid' in create_notification_parameters
'watch_mime_type': None,
'watch_tag': None,
@@ -410,6 +412,11 @@ class NotificationService:
n_object['notification_body'] = _check_cascading_vars(self.datastore,'notification_body', watch)
n_object['notification_format'] = _check_cascading_vars(self.datastore,'notification_format', watch)
# Attach LLM results so notification tokens render correctly
n_object['_llm_result'] = watch.get('_llm_result')
n_object['_llm_intent'] = watch.get('_llm_intent', '')
n_object['_llm_change_summary'] = watch.get('_llm_change_summary', '')
# (Individual watch) Only prepare to notify if the rules above matched
queued = False
if n_object and n_object.get('notification_urls'):

View File

@@ -61,7 +61,7 @@ def render_form(watch, datastore, request, url_for, render_template, flash, redi
screenshot=screenshot_url,
is_html_webdriver=is_html_webdriver,
password_enabled_and_share_is_off=password_enabled_and_share_is_off,
extra_title=f" - {watch.label} - {gettext('Extract Data')}",
extra_title=f" - {watch.label} - Extract Data",
extra_stylesheets=[url_for('static_content', group='styles', filename='diff.css')],
pure_menu_fixed=False
)

View File

@@ -3,11 +3,11 @@
{% block content %}
<div class="tabs">
<ul>
{% if last_error_text %}<li class="tab" id="error-text-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#error-text">{{ _('Error Text') }}</a></li> {% endif %}
{% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#error-screenshot">{{ _('Error Screenshot') }}</a></li> {% endif %}
<li class="tab" id=""><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#text">{{ _('Text') }}</a></li>
<li class="tab" id="screenshot-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#screenshot">{{ _('Screenshot') }}</a></li>
<li class="tab active" id="extract-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid)}}">{{ _('Extract Data') }}</a></li>
{% if last_error_text %}<li class="tab" id="error-text-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#error-text">Error Text</a></li> {% endif %}
{% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#error-screenshot">Error Screenshot</a></li> {% endif %}
<li class="tab" id=""><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#text">Text</a></li>
<li class="tab" id="screenshot-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#screenshot">Screenshot</a></li>
<li class="tab active" id="extract-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid)}}">Extract Data</a></li>
</ul>
</div>
@@ -17,23 +17,23 @@
<form id="extract-data-form" class="pure-form pure-form-stacked edit-form" action="{{ url_for('ui.ui_diff.diff_history_page_extract_POST', uuid=uuid) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<p>{{ _('This tool will extract text data from all of the watch history.') }}</p>
<p>This tool will extract text data from all of the watch history.</p>
<div class="pure-control-group">
{{ render_field(extract_form.extract_regex) }}
<span class="pure-form-message-inline">
{{ _('A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract.')|safe }}<br>
A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract.<br>
<p>
{{ _('For example, to extract only the numbers from text') }} &dash;<br>
<strong>{{ _('Raw text') }}</strong>: <code>Temperature <span style="color: red">5.5</span>°C in Sydney</code><br>
<strong>{{ _('RegEx to extract:') }}</strong> <code>Temperature <span style="color: red">([0-9\.]+)</span></code><br>
For example, to extract only the numbers from text &dash;<br>
<strong>Raw text</strong>: <code>Temperature <span style="color: red">5.5</span>°C in Sydney</code><br>
<strong>RegEx to extract:</strong> <code>Temperature <span style="color: red">([0-9\.]+)</span></code><br>
</p>
<p>
<a href="https://RegExr.com/">{{ _('Be sure to test your RegEx here.') }}</a>
<a href="https://RegExr.com/">Be sure to test your RegEx here.</a>
</p>
<p>
{{ _('Each RegEx group bracket') }} <code>()</code> {{ _('will be in its own column, the first column value is always the date.') }}
Each RegEx group bracket <code>()</code> will be in its own column, the first column value is always the date.
</p>
</span>
</div>

View File

@@ -65,6 +65,12 @@ 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)
# Apply llm_intent from form directly — it's not part of processor_text_json_diff_form
# but the AJAX sends all visible inputs, so it arrives in form_data
if hasattr(form_data, 'get') and 'llm_intent' in form_data:
tmp_watch['llm_intent'] = (form_data.get('llm_intent') or '').strip()
blank_watch_no_filters = watch_model(datastore_path=datastore.datastore_path, __datastore=datastore.data)
blank_watch_no_filters['url'] = tmp_watch.get('url')
@@ -120,6 +126,18 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
except Exception as e:
text_before_filter = f"Error: {str(e)}"
# LLM preview extraction — asks the LLM to directly answer the intent
# against the current filtered content (no diff comparison).
# e.g. intent "how many articles?" → answer "30 articles listed"
# Results are NOT cached back to the real watch.
llm_evaluation = None
try:
from changedetectionio.llm.evaluator import preview_extract
if text_after_filter and text_after_filter.strip() not in ('', 'Empty content'):
llm_evaluation = preview_extract(tmp_watch, datastore, content=text_after_filter)
except Exception as e:
logger.warning(f"LLM preview evaluation failed for {watch_uuid}: {e}")
logger.trace(f"Parsed in {time.time() - now:.3f}s")
return ({
@@ -128,6 +146,7 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
'blocked_line_numbers': blocked_line_numbers,
'duration': time.time() - now,
'ignore_line_numbers': ignore_line_numbers,
'llm_evaluation': llm_evaluation,
'trigger_line_numbers': trigger_line_numbers,
})

View File

@@ -7,7 +7,6 @@ a side-by-side or unified diff view with syntax highlighting and change markers.
import os
import time
from flask_babel import gettext
from loguru import logger
from changedetectionio import diff, strtobool
@@ -199,6 +198,27 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
if str(from_version) != str(dates[-2]) or str(to_version) != str(dates[-1]):
note = 'Note: You are not viewing the latest changes.'
llm_configured = bool(
datastore.data.get('settings', {}).get('application', {}).get('llm', {}).get('model')
)
# Load cached AI diff summary (only shown when viewing the latest snapshot)
llm_diff_summary = ''
viewing_latest = str(to_version) == str(dates[-1])
if viewing_latest and llm_configured:
try:
import difflib as _difflib
from changedetectionio.llm.evaluator import get_effective_summary_prompt, compute_summary_cache_key
_plain_diff = '\n'.join(list(_difflib.unified_diff(
from_version_file_contents.splitlines(),
to_version_file_contents.splitlines(),
lineterm='', n=3,
))[2:])
_cache_key = compute_summary_cache_key(_plain_diff, get_effective_summary_prompt(watch, datastore))
llm_diff_summary = watch.get_last_llm_diff_summary(cache_key=_cache_key)
except Exception as e:
logger.warning(f"Could not validate llm-diff-summary cache for {uuid}: {e}")
output = render_template("diff.html",
#initial_scroll_line_number=100,
bottom_horizontal_offscreen_contents=offscreen_content,
@@ -208,7 +228,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
diff_prefs=diff_prefs,
extra_classes='difference-page',
extra_stylesheets=extra_stylesheets,
extra_title=f" - {watch.label} - {gettext('History')}",
extra_title=f" - {watch.label} - History",
extract_form=extract_form,
from_version=str(from_version),
is_html_webdriver=is_html_webdriver,
@@ -225,5 +245,8 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
uuid=uuid,
versions=dates, # All except current/last
watch_a=watch,
llm_configured=llm_configured,
llm_diff_summary=llm_diff_summary,
viewing_latest=viewing_latest,
)
return output

View File

@@ -12,7 +12,14 @@ function request_textpreview_update() {
data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val();
});
// llm_intent lives in a separate (potentially hidden) tab — include it explicitly
const $llmIntent = $('textarea[name="llm_intent"]');
if ($llmIntent.length) {
data['llm_intent'] = $llmIntent.val();
}
$('body').toggleClass('spinner-active', 1);
$('#llm-preview-result').hide();
$.abortiveSingularAjax({
type: "POST",
@@ -41,6 +48,21 @@ function request_textpreview_update() {
'title': "No change-detection will occur because this text exists."
}
])
// LLM preview extraction result
const $llmResult = $('#llm-preview-result');
if ($llmResult.length && data['llm_evaluation']) {
const ev = data['llm_evaluation'];
const found = ev['found'];
$llmResult.attr('data-found', found ? '1' : '0');
$llmResult.find('.llm-preview-verdict').text(
found ? '✓ Would trigger a change' : '✗ Would not trigger a change'
);
$llmResult.find('.llm-preview-answer').text(ev['answer'] || '');
$llmResult.show();
} else if ($llmResult.length) {
$llmResult.hide();
}
}).fail(function (error) {
if (error.statusText === 'abort') {
console.log('Request was aborted due to a new request being fired.');

View File

@@ -1 +1 @@
#diff-form{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces;overflow-wrap:anywhere}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}
#diff-form{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces;overflow-wrap:anywhere}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}#llm-diff-summary-area{margin:.6rem 0 .4rem;padding:.65rem .9rem;background:linear-gradient(135deg, rgba(120, 80, 200, 0.08), rgba(80, 160, 220, 0.06));border-left:3px solid rgba(120,80,200,.5);border-radius:0 4px 4px 0;min-width:0;max-width:100%;box-sizing:border-box;overflow:hidden}#llm-diff-summary-area .llm-diff-summary-label{display:block;font-size:.7rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;opacity:.55;margin-bottom:.25rem}#llm-diff-summary-area .llm-diff-summary-text{margin:0;font-size:.9rem;line-height:1.5;white-space:pre-wrap;overflow-wrap:break-word;word-break:break-word}.llm-diff-summary-loading{opacity:.5;font-style:italic;animation:llm-pulse 1.4s ease-in-out infinite}@keyframes llm-pulse{0%,100%{opacity:.5}50%{opacity:.2}}

View File

@@ -245,4 +245,46 @@ body.difference-page {
padding-right: 1rem;
}
}
}
}
#llm-diff-summary-area {
margin: 0.6rem 0 0.4rem;
padding: 0.65rem 0.9rem;
background: linear-gradient(135deg, rgba(120, 80, 200, 0.08), rgba(80, 160, 220, 0.06));
border-left: 3px solid rgba(120, 80, 200, 0.5);
border-radius: 0 4px 4px 0;
/* prevent long unbroken text from expanding the container */
min-width: 0;
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
.llm-diff-summary-label {
display: block;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.55;
margin-bottom: 0.25rem;
}
.llm-diff-summary-text {
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
}
}
.llm-diff-summary-loading {
opacity: 0.5;
font-style: italic;
animation: llm-pulse 1.4s ease-in-out infinite;
}
@keyframes llm-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.2; }
}

View File

@@ -1,7 +1,7 @@
{% from '_helpers.html' import render_field %}
{% macro show_token_placeholders(extra_notification_token_placeholder_info, suffix="") %}
{% macro show_token_placeholders(extra_notification_token_placeholder_info, suffix="", settings_application=None) %}
<div class="pure-controls">
@@ -114,7 +114,25 @@
<tr>
<td><code>{{ '{{triggered_text}}' }}</code></td>
<td>{{ _('Text that tripped the trigger from filters') }}</td>
</tr>
{% if settings_application and settings_application.get('llm', {}).get('model') %}
<tr>
<td><code>{{ '{{diff}}' }}</code> <small style="opacity:0.6">{{ _('(upgraded)') }}</small></td>
<td>{{ _('When AI Change Summary is configured, contains the AI-generated description instead of the raw diff. Falls back to raw diff when not configured.') }}</td>
</tr>
<tr>
<td><code>{{ '{{raw_diff}}' }}</code></td>
<td>{{ _('Always the raw +/- diff, regardless of AI Change Summary setting.') }}</td>
</tr>
<tr>
<td><code>{{ '{{llm_summary}}' }}</code></td>
<td>{{ _('The AI Change Summary text (same as the upgraded {{diff}} — explicit reference).') }}</td>
</tr>
<tr>
<td><code>{{ '{{llm_intent}}' }}</code></td>
<td>{{ _('The AI Change Intent that was evaluated.') }}</td>
</tr>
{% endif %}
{% if extra_notification_token_placeholder_info %}
{% for token in extra_notification_token_placeholder_info %}
<tr>
@@ -173,7 +191,7 @@
</div>
<div class="pure-control-group">
{{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }}
{{ show_token_placeholders(extra_notification_token_placeholder_info=extra_notification_token_placeholder_info) }}
{{ show_token_placeholders(extra_notification_token_placeholder_info=extra_notification_token_placeholder_info, settings_application=settings_application) }}
<div class="pure-form-message-inline">
<ul>
<li><span class="pure-form-message-inline">

View File

@@ -69,7 +69,7 @@
{% else %}
{% if new_version_available and not(has_password and not current_user.is_authenticated) %}
<span id="new-version-text" class="pure-menu-heading">
<a href="https://changedetection.io">{{ _('A new version is available') }}</a>
<a href="https://changedetection.io">A new version is available</a>
</span>
{% endif %}
{% endif %}
@@ -235,7 +235,7 @@
{% if session['share-link'] %}
<ul class="messages with-share-link">
<li class="message">
{{ _('Share this link:') }}
Share this link:
<span id="share-link">{{ session['share-link'] }}</span>
<img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}" >
</li>

View File

@@ -0,0 +1,126 @@
{#
AI Intent + AI Change Summary section — shared include for watch edit and tag/group edit.
Required template context:
llm_configured — bool: LLM provider is configured in settings
form — the WTForms form (must have .llm_intent and .llm_change_summary fields)
Optional (watch edit only):
watch — the Watch object (for processor check and prefilter display)
llm_intent_source — str: 'watch', tag title, or '' (for inherited-from-tag hint)
Usage in watch edit (edit.html):
{% include "edit/include_llm_intent.html" %}
Usage in tag edit (edit-tag.html):
{% include "edit/include_llm_intent.html" %}
(watch is not set → tag mode: no processor check, no examples, different description)
#}
{# Watch edit: only show for text_json_diff processor #}
{% if watch is defined and watch %}
{% set is_text_json_diff = not watch.get('processor') or watch.get('processor') == 'text_json_diff' %}
{% else %}
{% set is_text_json_diff = true %}
{% endif %}
{% if is_text_json_diff %}
{# ── Configured: show the intent + summary textareas ─────────────── #}
{% if llm_configured %}
<div class="border-fieldset" id="llm-intent-section">
<h3>&#x2728; {{ _('AI') }}</h3>
{# — AI Change Intent — #}
<h4 style="margin: 0 0 0.3em 0;">{{ _('AI Change Intent') }}</h4>
<p class="pure-form-message-inline" style="margin-top:0">
{% if watch is defined and watch %}
{{ _('Describe what you care about. The AI evaluates every detected change against this and only notifies you when it matches.') }}
{% if llm_intent_source is defined and llm_intent_source and llm_intent_source != 'watch' %}
<br><em>{{ _('Inherited from tag:') }} <strong>{{ llm_intent_source }}</strong> — {{ _('type here to override for this watch only') }}</em>
{% endif %}
{% else %}
{{ _('Set a change intent for all watches in this tag/group. Each watch can override with its own.') }}
{% endif %}
</p>
<div class="pure-control-group">
<textarea name="llm_intent"
id="llm_intent"
rows="3"
class="pure-input-1"
{% if watch is defined and watch %}
placeholder="{{ _('e.g. Alert me when the price drops below $300, or a new product is launched. Ignore footer and navigation changes.') }}"
{% else %}
placeholder="{{ _('e.g. Flag price changes or new product launches across all watches in this group') }}"
{% endif %}
>{{ form.llm_intent.data or '' }}</textarea>
</div>
{% if watch is defined and watch %}
<div class="pure-form-message-inline">
<strong>{{ _('Examples:') }}</strong>
<ul style="margin: 0.3em 0 0 1.2em; padding: 0;">
<li><em>{{ _('Only notify if the price drops below $200, or a limited-time deal is added') }}</em></li>
<li><em>{{ _('Alert when a new recall, safety notice, or product withdrawal is published') }}</em></li>
<li><em>{{ _('Notify when a new grant round opens or an application deadline is announced') }}</em></li>
<li><em>{{ _('Only important if package versions change or a CVE is mentioned') }}</em></li>
</ul>
</div>
{% if watch.get('llm_prefilter') %}
<div class="pure-form-message-inline" style="margin-top: 0.5em;">
<small>{{ _('AI pre-filter active:') }} <code>{{ watch.get('llm_prefilter') }}</code>
— {{ _('narrows content scope before evaluation') }}</small>
</div>
{% endif %}
{% endif %}
<hr style="margin: 1.2em 0; border: none; border-top: 1px solid var(--color-border, #ddd);">
{# — AI Change Summary — #}
<h4 style="margin: 0 0 0.3em 0;">{{ _('AI Change Summary') }}</h4>
<p class="pure-form-message-inline" style="margin-top:0">
{% if watch is defined and watch %}
{{ _('When a change is detected, the AI describes it according to your instructions and replaces') }} <code>{{ '{{diff}}' }}</code> {{ _('in your notification. Use') }} <code>{{ '{{raw_diff}}' }}</code> {{ _('if you still want the original diff.') }}
{% else %}
{{ _('Describe how changes should be summarised in notifications for all watches in this group.') }}
{% endif %}
</p>
<div class="pure-control-group">
<textarea name="llm_change_summary"
id="llm_change_summary"
rows="3"
class="pure-input-1"
{% if watch is defined and watch %}
placeholder="{{ _('e.g. List what was added or removed as bullet points. Translate to English.') }}"
{% else %}
placeholder="{{ _('e.g. Summarise new items added. Translate to English if needed.') }}"
{% endif %}
>{{ form.llm_change_summary.data or '' }}</textarea>
</div>
{% if watch is defined and watch %}
<div class="pure-form-message-inline">
<strong>{{ _('Examples:') }}</strong>
<ul style="margin: 0.3em 0 0 1.2em; padding: 0;">
<li><em>{{ _('List each new item added with its name and price. Translate to English.') }}</em></li>
<li><em>{{ _('Summarise what events were added or cancelled. Two sentences maximum.') }}</em></li>
<li><em>{{ _('Describe the price change: old price, new price, percentage difference.') }}</em></li>
</ul>
</div>
{% endif %}
</div>
{# ── Not configured: greyed-out prompt to configure ──────────────── #}
{% else %}
<div class="border-fieldset" id="llm-intent-section-disabled" style="opacity: 0.5;">
<h3>&#x2728; {{ _('AI') }}</h3>
<p>{{ _('Configure an AI/LLM provider in') }}
<a href="{{ url_for('settings.settings_page') }}#ai">{{ _('Settings → AI/LLM') }}</a>
{% if watch is defined and watch %}
{{ _('to enable AI Change Intent and AI Change Summary.') }}
{% else %}
{{ _('to enable AI features for this group.') }}
{% endif %}
</p>
</div>
{% endif %}
{% endif %}{# is_text_json_diff #}

View File

View File

@@ -0,0 +1,560 @@
"""
Unit tests for changedetectionio/llm/evaluator.py
Uses mocked LLM calls — no real API key needed.
"""
import pytest
from unittest.mock import patch, MagicMock
def _make_datastore(llm_cfg=None, tags=None):
"""Build a minimal datastore-like dict for testing."""
ds = MagicMock()
app_settings = {
'llm': llm_cfg or {},
'tags': tags or {},
}
ds.data = {
'settings': {
'application': app_settings,
}
}
return ds
def _make_watch(llm_intent='', llm_change_summary='', tags=None, uuid='test-uuid-1234'):
w = {}
w['llm_intent'] = llm_intent
w['llm_change_summary'] = llm_change_summary
w['tags'] = tags or []
w['uuid'] = uuid
w['url'] = 'https://example.com'
w['page_title'] = 'Test Page'
w['llm_evaluation_cache'] = {}
w['llm_prefilter'] = None
return w
# ---------------------------------------------------------------------------
# resolve_intent
# ---------------------------------------------------------------------------
class TestResolveIntent:
def test_watch_intent_takes_priority(self):
from changedetectionio.llm.evaluator import resolve_intent
tag = {'title': 'mygroup', 'llm_intent': 'group intent'}
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_intent='watch intent', tags=['tag-1'])
intent, source = resolve_intent(watch, ds)
assert intent == 'watch intent'
assert source == 'watch'
def test_tag_intent_used_when_watch_has_none(self):
from changedetectionio.llm.evaluator import resolve_intent
tag = {'title': 'pricing-group', 'llm_intent': 'flag price drops'}
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_intent='', tags=['tag-1'])
intent, source = resolve_intent(watch, ds)
assert intent == 'flag price drops'
assert source == 'pricing-group'
def test_no_intent_anywhere_returns_empty(self):
from changedetectionio.llm.evaluator import resolve_intent
ds = _make_datastore()
watch = _make_watch(llm_intent='')
intent, source = resolve_intent(watch, ds)
assert intent == ''
assert source == ''
def test_tag_applied_to_all_watches_in_group(self):
"""Tag intent propagates to every watch in the tag (no opt-in needed)."""
from changedetectionio.llm.evaluator import resolve_intent
tag = {'title': 'job-board', 'llm_intent': 'new engineering jobs'}
ds = _make_datastore(tags={'tag-1': tag})
# Three different watches, all in the tag, none have their own intent
for watch_uuid in ['uuid-A', 'uuid-B', 'uuid-C']:
watch = _make_watch(llm_intent='', tags=['tag-1'], uuid=watch_uuid)
intent, source = resolve_intent(watch, ds)
assert intent == 'new engineering jobs', f"Watch {watch_uuid} should inherit tag intent"
assert source == 'job-board'
def test_whitespace_only_intent_treated_as_empty(self):
from changedetectionio.llm.evaluator import resolve_intent
ds = _make_datastore()
watch = _make_watch(llm_intent=' ')
intent, source = resolve_intent(watch, ds)
assert intent == ''
def test_missing_tag_in_datastore_skipped(self):
from changedetectionio.llm.evaluator import resolve_intent
ds = _make_datastore(tags={}) # no tags registered
watch = _make_watch(llm_intent='', tags=['nonexistent-tag'])
intent, source = resolve_intent(watch, ds)
assert intent == ''
# ---------------------------------------------------------------------------
# get_llm_config
# ---------------------------------------------------------------------------
class TestGetLlmConfig:
def test_returns_none_when_no_model(self):
from changedetectionio.llm.evaluator import get_llm_config
ds = _make_datastore(llm_cfg={})
assert get_llm_config(ds) is None
def test_returns_config_when_model_set(self):
from changedetectionio.llm.evaluator import get_llm_config
cfg = {'model': 'gpt-4o-mini', 'api_key': 'sk-test'}
ds = _make_datastore(llm_cfg=cfg)
result = get_llm_config(ds)
assert result['model'] == 'gpt-4o-mini'
def test_env_var_overrides_datastore(self):
"""LLM_MODEL env var takes priority over datastore settings."""
from changedetectionio.llm.evaluator import get_llm_config
ds = _make_datastore(llm_cfg={'model': 'datastore-model'})
with patch.dict('os.environ', {'LLM_MODEL': 'ollama/llama3.2', 'LLM_API_KEY': '', 'LLM_API_BASE': ''}):
result = get_llm_config(ds)
assert result['model'] == 'ollama/llama3.2'
def test_env_var_api_key_and_base_included(self):
"""LLM_API_KEY and LLM_API_BASE are picked up alongside LLM_MODEL."""
from changedetectionio.llm.evaluator import get_llm_config
ds = _make_datastore()
env = {'LLM_MODEL': 'gpt-4o', 'LLM_API_KEY': 'env-key', 'LLM_API_BASE': 'http://localhost:11434'}
with patch.dict('os.environ', env):
result = get_llm_config(ds)
assert result['api_key'] == 'env-key'
assert result['api_base'] == 'http://localhost:11434'
def test_llm_configured_via_env_true_when_model_set(self):
"""llm_configured_via_env() returns True when LLM_MODEL is set."""
from changedetectionio.llm.evaluator import llm_configured_via_env
with patch.dict('os.environ', {'LLM_MODEL': 'gpt-4o-mini'}):
assert llm_configured_via_env() is True
def test_llm_configured_via_env_false_when_not_set(self):
"""llm_configured_via_env() returns False when LLM_MODEL is absent."""
from changedetectionio.llm.evaluator import llm_configured_via_env
env = {k: '' for k in ['LLM_MODEL', 'LLM_API_KEY', 'LLM_API_BASE']}
with patch.dict('os.environ', env, clear=False):
# Ensure LLM_MODEL is truly absent
import os
os.environ.pop('LLM_MODEL', None)
assert llm_configured_via_env() is False
# ---------------------------------------------------------------------------
# evaluate_change
# ---------------------------------------------------------------------------
class TestEvaluateChange:
def test_returns_none_when_llm_not_configured(self):
from changedetectionio.llm.evaluator import evaluate_change
ds = _make_datastore(llm_cfg={}) # no model
watch = _make_watch(llm_intent='flag price drops')
result = evaluate_change(watch, ds, diff='- $500\n+ $400')
assert result is None
def test_returns_none_when_no_intent(self):
from changedetectionio.llm.evaluator import evaluate_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'})
watch = _make_watch(llm_intent='')
result = evaluate_change(watch, ds, diff='some diff')
assert result is None
def test_returns_not_important_for_empty_diff(self):
from changedetectionio.llm.evaluator import evaluate_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'})
watch = _make_watch(llm_intent='flag price drops')
result = evaluate_change(watch, ds, diff='')
assert result == {'important': False, 'summary': ''}
def test_returns_not_important_for_whitespace_diff(self):
from changedetectionio.llm.evaluator import evaluate_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'})
watch = _make_watch(llm_intent='flag price drops')
result = evaluate_change(watch, ds, diff=' \n ')
assert result == {'important': False, 'summary': ''}
def test_calls_llm_and_returns_result(self):
from changedetectionio.llm.evaluator import evaluate_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'api_key': 'sk-test'})
watch = _make_watch(llm_intent='flag price drops')
llm_response = '{"important": true, "summary": "Price dropped from $500 to $400"}'
with patch('changedetectionio.llm.client.completion', return_value=(llm_response, 150)):
result = evaluate_change(watch, ds, diff='- $500\n+ $400')
assert result['important'] is True
assert 'Price dropped' in result['summary']
def test_cache_hit_skips_llm_call(self):
from changedetectionio.llm.evaluator import evaluate_change
import hashlib
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'api_key': 'sk-test'})
watch = _make_watch(llm_intent='flag price drops')
diff = '- $500\n+ $400'
intent = 'flag price drops'
cache_key = hashlib.sha256(f"{intent}||{diff}".encode()).hexdigest()
watch['llm_evaluation_cache'] = {
cache_key: {'important': True, 'summary': 'cached result'}
}
with patch('changedetectionio.llm.client.completion') as mock_llm:
result = evaluate_change(watch, ds, diff=diff)
mock_llm.assert_not_called()
assert result['summary'] == 'cached result'
def test_llm_failure_returns_important_true(self):
"""On LLM error, notification should NOT be suppressed (fail open)."""
from changedetectionio.llm.evaluator import evaluate_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'api_key': 'sk-test'})
watch = _make_watch(llm_intent='flag price drops')
with patch('changedetectionio.llm.client.completion', side_effect=Exception('API timeout')):
result = evaluate_change(watch, ds, diff='- $500\n+ $400')
assert result['important'] is True
assert result['summary'] == ''
def test_unimportant_result_from_llm(self):
from changedetectionio.llm.evaluator import evaluate_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'})
watch = _make_watch(llm_intent='only alert on price drops')
llm_response = '{"important": false, "summary": "Only a footer copyright year changed"}'
with patch('changedetectionio.llm.client.completion', return_value=(llm_response, 45)):
result = evaluate_change(watch, ds, diff='- Copyright 2023\n+ Copyright 2024')
assert result['important'] is False
assert 'footer' in result['summary'].lower() or 'copyright' in result['summary'].lower()
def test_last_tokens_used_stored_after_eval(self):
"""watch['llm_last_tokens_used'] is set to the token count after a successful call."""
from changedetectionio.llm.evaluator import evaluate_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'})
watch = _make_watch(llm_intent='flag price drops')
llm_response = '{"important": true, "summary": "Price fell"}'
with patch('changedetectionio.llm.client.completion', return_value=(llm_response, 123)):
evaluate_change(watch, ds, diff='- $500\n+ $300')
assert watch.get('llm_last_tokens_used') == 123
def test_cumulative_tokens_accumulate_across_evals(self):
"""Each eval adds its tokens to watch['llm_tokens_used_cumulative']."""
from changedetectionio.llm.evaluator import evaluate_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'})
watch = _make_watch(llm_intent='flag price drops')
resp1 = '{"important": true, "summary": "First"}'
resp2 = '{"important": false, "summary": "Second"}'
with patch('changedetectionio.llm.client.completion', return_value=(resp1, 80)):
evaluate_change(watch, ds, diff='- $500\n+ $400')
# Second call needs a different diff to avoid cache hit
with patch('changedetectionio.llm.client.completion', return_value=(resp2, 60)):
evaluate_change(watch, ds, diff='- $400\n+ $350')
assert watch.get('llm_tokens_used_cumulative') == 140
# ---------------------------------------------------------------------------
# Token budget enforcement
# ---------------------------------------------------------------------------
class TestTokenBudget:
def test_no_limits_always_returns_true(self):
"""When no limits configured, budget check always passes."""
from changedetectionio.llm.evaluator import _check_token_budget
watch = _make_watch()
cfg = {} # no limits
assert _check_token_budget(watch, cfg, tokens_this_call=10_000) is True
def test_per_check_limit_exceeded_returns_false(self):
"""Tokens on this call exceeding per-check limit → False."""
from changedetectionio.llm.evaluator import _check_token_budget
watch = _make_watch()
cfg = {'max_tokens_per_check': 100}
result = _check_token_budget(watch, cfg, tokens_this_call=150)
assert result is False
def test_per_check_limit_not_exceeded_returns_true(self):
"""Tokens on this call within per-check limit → True."""
from changedetectionio.llm.evaluator import _check_token_budget
watch = _make_watch()
cfg = {'max_tokens_per_check': 200}
result = _check_token_budget(watch, cfg, tokens_this_call=150)
assert result is True
def test_cumulative_limit_exceeded_returns_false(self):
"""Total accumulated tokens exceeding cumulative limit → False."""
from changedetectionio.llm.evaluator import _check_token_budget
watch = _make_watch()
watch['llm_tokens_used_cumulative'] = 900
cfg = {'max_tokens_cumulative': 1000}
# This call adds 200 → total 1100 > 1000
result = _check_token_budget(watch, cfg, tokens_this_call=200)
assert result is False
def test_cumulative_limit_not_yet_exceeded_returns_true(self):
"""Total accumulated tokens within cumulative limit → True."""
from changedetectionio.llm.evaluator import _check_token_budget
watch = _make_watch()
watch['llm_tokens_used_cumulative'] = 500
cfg = {'max_tokens_cumulative': 1000}
result = _check_token_budget(watch, cfg, tokens_this_call=100)
assert result is True
def test_tokens_accumulated_into_watch(self):
"""tokens_this_call is added to watch['llm_tokens_used_cumulative']."""
from changedetectionio.llm.evaluator import _check_token_budget
watch = _make_watch()
watch['llm_tokens_used_cumulative'] = 300
cfg = {}
_check_token_budget(watch, cfg, tokens_this_call=75)
assert watch['llm_tokens_used_cumulative'] == 375
def test_zero_tokens_call_does_not_change_cumulative(self):
"""Calling with tokens_this_call=0 (pre-flight check) doesn't modify cumulative."""
from changedetectionio.llm.evaluator import _check_token_budget
watch = _make_watch()
watch['llm_tokens_used_cumulative'] = 200
cfg = {}
_check_token_budget(watch, cfg, tokens_this_call=0)
assert watch['llm_tokens_used_cumulative'] == 200
def test_evaluate_change_skips_call_when_cumulative_over_budget(self):
"""Pre-flight cumulative check: if already over budget, skip LLM call and fail open."""
from changedetectionio.llm.evaluator import evaluate_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'max_tokens_cumulative': 100})
watch = _make_watch(llm_intent='flag price drops')
watch['llm_tokens_used_cumulative'] = 500 # already far over
with patch('changedetectionio.llm.client.completion') as mock_llm:
result = evaluate_change(watch, ds, diff='- $500\n+ $400')
mock_llm.assert_not_called()
# Fail open: important=True so the notification is NOT suppressed
assert result == {'important': True, 'summary': ''}
def test_evaluate_change_per_check_limit_fails_open(self):
"""Per-check token exceeded after call → result still returned (fail open)."""
from changedetectionio.llm.evaluator import evaluate_change
# max_tokens_per_check is 50, but the call returns 150 tokens
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'max_tokens_per_check': 50})
watch = _make_watch(llm_intent='flag price drops')
llm_response = '{"important": false, "summary": "Only minor change"}'
with patch('changedetectionio.llm.client.completion', return_value=(llm_response, 150)):
result = evaluate_change(watch, ds, diff='- $500\n+ $499')
# LLM said not important, but even with per-check warning the result is returned
# (budget warning is logged but evaluation result is still used)
assert result is not None
assert 'important' in result
# ---------------------------------------------------------------------------
# resolve_llm_field (generic cascade)
# ---------------------------------------------------------------------------
class TestResolveLlmField:
def test_watch_value_takes_priority(self):
from changedetectionio.llm.evaluator import resolve_llm_field
tag = {'title': 'mygroup', 'llm_change_summary': 'tag summary prompt'}
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_change_summary='watch summary prompt', tags=['tag-1'])
value, source = resolve_llm_field(watch, ds, 'llm_change_summary')
assert value == 'watch summary prompt'
assert source == 'watch'
def test_tag_value_used_when_watch_empty(self):
from changedetectionio.llm.evaluator import resolve_llm_field
tag = {'title': 'events-group', 'llm_change_summary': 'list new events'}
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_change_summary='', tags=['tag-1'])
value, source = resolve_llm_field(watch, ds, 'llm_change_summary')
assert value == 'list new events'
assert source == 'events-group'
def test_returns_empty_when_not_set_anywhere(self):
from changedetectionio.llm.evaluator import resolve_llm_field
ds = _make_datastore()
watch = _make_watch()
value, source = resolve_llm_field(watch, ds, 'llm_change_summary')
assert value == ''
assert source == ''
def test_works_for_llm_intent_field_too(self):
"""resolve_llm_field is generic — works for llm_intent same as llm_change_summary."""
from changedetectionio.llm.evaluator import resolve_llm_field
tag = {'title': 'grp', 'llm_intent': 'flag price drops'}
ds = _make_datastore(tags={'t1': tag})
watch = _make_watch(llm_intent='', tags=['t1'])
value, source = resolve_llm_field(watch, ds, 'llm_intent')
assert value == 'flag price drops'
# ---------------------------------------------------------------------------
# summarise_change
# ---------------------------------------------------------------------------
class TestSummariseChange:
def test_returns_empty_when_llm_not_configured(self):
from changedetectionio.llm.evaluator import summarise_change
ds = _make_datastore(llm_cfg={})
watch = _make_watch(llm_change_summary='List what changed')
result = summarise_change(watch, ds, diff='- old\n+ new')
assert result == ''
def test_uses_default_prompt_when_no_summary_prompt(self):
"""When llm_change_summary is empty, falls back to DEFAULT_CHANGE_SUMMARY_PROMPT."""
from changedetectionio.llm.evaluator import summarise_change, DEFAULT_CHANGE_SUMMARY_PROMPT
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'api_key': 'sk-test'})
watch = _make_watch(llm_change_summary='')
with patch('changedetectionio.llm.client.completion',
return_value=('A new item was added.', 40)) as mock_llm:
result = summarise_change(watch, ds, diff='- old\n+ new')
mock_llm.assert_called_once()
# Default prompt must appear in the user message
call_messages = mock_llm.call_args.kwargs['messages']
user_msg = next(m['content'] for m in call_messages if m['role'] == 'user')
assert DEFAULT_CHANGE_SUMMARY_PROMPT in user_msg
assert result == 'A new item was added.'
def test_returns_empty_when_diff_empty(self):
from changedetectionio.llm.evaluator import summarise_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'})
watch = _make_watch(llm_change_summary='List what changed')
with patch('changedetectionio.llm.client.completion') as mock_llm:
result = summarise_change(watch, ds, diff='')
mock_llm.assert_not_called()
assert result == ''
def test_calls_llm_and_returns_plain_text(self):
from changedetectionio.llm.evaluator import summarise_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'api_key': 'sk-test'})
watch = _make_watch(llm_change_summary='List new events in English')
with patch('changedetectionio.llm.client.completion',
return_value=('3 new events added: Jazz Night, Art Show, Comedy Gig', 80)):
result = summarise_change(watch, ds, diff='+ Jazz Night\n+ Art Show\n+ Comedy Gig')
assert 'Jazz Night' in result
assert 'Art Show' in result
def test_cascades_from_tag(self):
"""llm_change_summary on a tag propagates to watches in that tag."""
from changedetectionio.llm.evaluator import summarise_change
tag = {'title': 'events', 'llm_change_summary': 'Translate events to English'}
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}, tags={'tag-1': tag})
watch = _make_watch(llm_change_summary='', tags=['tag-1'])
with patch('changedetectionio.llm.client.completion',
return_value=('New concert added on Friday', 60)):
result = summarise_change(watch, ds, diff='+ Konzert am Freitag')
assert result == 'New concert added on Friday'
def test_llm_failure_returns_empty_string(self):
"""On LLM error, returns '' — the caller falls back to raw diff."""
from changedetectionio.llm.evaluator import summarise_change
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'})
watch = _make_watch(llm_change_summary='Describe the change')
with patch('changedetectionio.llm.client.completion', side_effect=Exception('timeout')):
result = summarise_change(watch, ds, diff='- old\n+ new')
assert result == ''
def test_uses_higher_token_limit_than_eval(self):
"""summarise_change passes max_tokens=500 to client, not the default 200."""
from changedetectionio.llm.evaluator import summarise_change, _MAX_SUMMARY_TOKENS
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'})
watch = _make_watch(llm_change_summary='Describe changes')
with patch('changedetectionio.llm.client.completion',
return_value=('Some summary', 100)) as mock_llm:
summarise_change(watch, ds, diff='- old\n+ new')
call_kwargs = mock_llm.call_args
assert call_kwargs.kwargs.get('max_tokens') == _MAX_SUMMARY_TOKENS
# ---------------------------------------------------------------------------
# compute_summary_cache_key / get_effective_summary_prompt
# ---------------------------------------------------------------------------
class TestSummaryCacheKey:
def test_same_inputs_produce_same_key(self):
from changedetectionio.llm.evaluator import compute_summary_cache_key
key1 = compute_summary_cache_key('+ new line', 'describe changes')
key2 = compute_summary_cache_key('+ new line', 'describe changes')
assert key1 == key2
def test_different_diff_produces_different_key(self):
from changedetectionio.llm.evaluator import compute_summary_cache_key
key1 = compute_summary_cache_key('+ line A', 'prompt')
key2 = compute_summary_cache_key('+ line B', 'prompt')
assert key1 != key2
def test_different_prompt_produces_different_key(self):
from changedetectionio.llm.evaluator import compute_summary_cache_key
key1 = compute_summary_cache_key('diff', 'list changes')
key2 = compute_summary_cache_key('diff', 'translate to English')
assert key1 != key2
def test_key_is_16_hex_chars(self):
from changedetectionio.llm.evaluator import compute_summary_cache_key
key = compute_summary_cache_key('diff', 'prompt')
assert len(key) == 16
assert all(c in '0123456789abcdef' for c in key)
def test_get_effective_prompt_returns_custom_when_set(self):
from changedetectionio.llm.evaluator import get_effective_summary_prompt
ds = _make_datastore()
watch = _make_watch(llm_change_summary='My custom prompt')
assert get_effective_summary_prompt(watch, ds) == 'My custom prompt'
def test_get_effective_prompt_returns_default_when_empty(self):
from changedetectionio.llm.evaluator import get_effective_summary_prompt, DEFAULT_CHANGE_SUMMARY_PROMPT
ds = _make_datastore()
watch = _make_watch(llm_change_summary='')
assert get_effective_summary_prompt(watch, ds) == DEFAULT_CHANGE_SUMMARY_PROMPT
def test_get_effective_prompt_cascades_from_tag(self):
from changedetectionio.llm.evaluator import get_effective_summary_prompt
tag = {'title': 'grp', 'llm_change_summary': 'tag-level prompt'}
ds = _make_datastore(tags={'t1': tag})
watch = _make_watch(llm_change_summary='', tags=['t1'])
assert get_effective_summary_prompt(watch, ds) == 'tag-level prompt'

View File

@@ -0,0 +1,325 @@
"""
Tests that {{ llm_summary }} and {{ llm_intent }} notification tokens
are correctly populated in the notification pipeline.
Covers:
1. notification/handler.py — lazy population logic (lines 367-372)
2. notification_service.py — _llm_result / _llm_intent from watch → n_object
3. End-to-end: tokens render in notification body/title
"""
import pytest
from unittest.mock import MagicMock, patch
from changedetectionio.notification_service import NotificationContextData
def _make_n_object(**extra):
n = NotificationContextData()
n.update({
'notification_body': '',
'notification_title': '',
'notification_format': 'text',
'notification_urls': ['json://localhost/'],
'uuid': 'test-uuid',
'watch_uuid': 'test-uuid',
'watch_url': 'https://example.com',
'current_snapshot': 'current text',
'prev_snapshot': 'previous text',
})
n.update(extra)
return n
# ---------------------------------------------------------------------------
# handler.py — lazy population of llm_summary / llm_intent
# ---------------------------------------------------------------------------
class TestHandlerLlmTokenPopulation:
"""
The notification handler checks if llm_summary or llm_intent tokens appear
in the notification text and lazily populates them from _llm_result.
"""
def _run_handler_llm_section(self, n_object):
"""
Replicate the exact logic from notification/handler.py lines 367-372.
This is tested directly to validate the handler's token population.
"""
scan_text = n_object.get('notification_body', '') + n_object.get('notification_title', '')
if 'llm_summary' in scan_text or 'llm_intent' in scan_text:
llm_result = n_object.get('_llm_result') or {}
n_object['llm_summary'] = llm_result.get('summary', '')
n_object['llm_intent'] = n_object.get('_llm_intent', '')
return n_object
def test_llm_summary_populated_when_token_in_body(self):
n = _make_n_object(
notification_body='Change detected! Summary: {{ llm_summary }}',
_llm_result={'important': True, 'summary': 'Price dropped from $500 to $400'},
_llm_intent='flag price drops',
)
result = self._run_handler_llm_section(n)
assert result['llm_summary'] == 'Price dropped from $500 to $400'
def test_llm_intent_populated_when_token_in_body(self):
n = _make_n_object(
notification_body='Intent was: {{ llm_intent }}',
_llm_result={'important': True, 'summary': 'some change'},
_llm_intent='flag price drops',
)
result = self._run_handler_llm_section(n)
assert result['llm_intent'] == 'flag price drops'
def test_llm_summary_in_title(self):
n = _make_n_object(
notification_title='[CD] {{ llm_summary }}',
notification_body='some body',
_llm_result={'important': True, 'summary': 'New job posted'},
_llm_intent='new jobs',
)
result = self._run_handler_llm_section(n)
assert result['llm_summary'] == 'New job posted'
def test_tokens_not_populated_when_absent_from_template(self):
"""Don't bother populating when tokens aren't used — avoid needless LLM calls."""
n = _make_n_object(
notification_body='Change at {{ watch_url }}',
notification_title='CD Alert',
_llm_result={'important': True, 'summary': 'should not appear'},
_llm_intent='test',
)
result = self._run_handler_llm_section(n)
# llm_summary and llm_intent should remain at their default None values
assert result.get('llm_summary') is None
assert result.get('llm_intent') is None
def test_empty_summary_when_no_llm_result(self):
n = _make_n_object(
notification_body='Summary: {{ llm_summary }}',
_llm_result=None,
_llm_intent='',
)
result = self._run_handler_llm_section(n)
assert result['llm_summary'] == ''
def test_empty_intent_when_not_set(self):
n = _make_n_object(
notification_body='Intent: {{ llm_intent }}',
_llm_result={'important': False, 'summary': ''},
)
result = self._run_handler_llm_section(n)
assert result['llm_intent'] == ''
def test_summary_from_unimportant_result(self):
"""Even when important=False the summary explains why — useful for debugging."""
n = _make_n_object(
notification_body='Summary: {{ llm_summary }}',
_llm_result={'important': False, 'summary': 'Only a copyright year changed'},
_llm_intent='flag price drops',
)
result = self._run_handler_llm_section(n)
assert result['llm_summary'] == 'Only a copyright year changed'
# ---------------------------------------------------------------------------
# notification_service.py — _llm_result / _llm_intent wired from watch
# ---------------------------------------------------------------------------
class TestNotificationServiceLlmAttachment:
"""
send_content_changed_notification() reads _llm_result and _llm_intent
from the watch object and attaches them to n_object so the handler can render tokens.
"""
def _make_watch(self, llm_result=None, llm_intent=''):
watch = MagicMock()
watch.get.side_effect = lambda key, default=None: {
'_llm_result': llm_result,
'_llm_intent': llm_intent,
'notification_urls': ['json://localhost/'],
'notification_title': '',
'notification_body': '',
'notification_format': 'text',
'notification_muted': False,
'notification_alert_count': 0,
}.get(key, default)
watch.history = {'1000': 'snap1', '2000': 'snap2'}
watch.get_history_snapshot = MagicMock(return_value='snapshot text')
watch.extra_notification_token_values = MagicMock(return_value={})
return watch
def test_llm_result_attached_to_n_object(self):
"""_llm_result from watch ends up in n_object for the notification handler."""
from changedetectionio.notification_service import NotificationService
llm_result = {'important': True, 'summary': 'Price dropped'}
watch = self._make_watch(llm_result=llm_result, llm_intent='flag price drops')
datastore = MagicMock()
datastore.data = {
'settings': {
'application': {
'active_base_url': 'http://localhost',
'notification_urls': [],
'notification_title': '',
'notification_body': '',
'notification_format': 'text',
'notification_muted': False,
}
},
'watching': {'test-uuid': watch},
}
datastore.get_all_tags_for_watch = MagicMock(return_value={})
captured = {}
def fake_queue_notification(n_object, watch, **kwargs):
captured['n_object'] = dict(n_object)
svc = NotificationService(datastore, MagicMock())
svc.queue_notification_for_watch = fake_queue_notification
svc.send_content_changed_notification('test-uuid')
assert '_llm_result' in captured['n_object']
assert captured['n_object']['_llm_result'] == llm_result
def test_llm_intent_attached_to_n_object(self):
"""_llm_intent from watch ends up in n_object."""
from changedetectionio.notification_service import NotificationService
watch = self._make_watch(
llm_result={'important': True, 'summary': 'test'},
llm_intent='flag price drops',
)
datastore = MagicMock()
datastore.data = {
'settings': {
'application': {
'active_base_url': 'http://localhost',
'notification_urls': [],
'notification_title': '',
'notification_body': '',
'notification_format': 'text',
'notification_muted': False,
}
},
'watching': {'test-uuid': watch},
}
datastore.get_all_tags_for_watch = MagicMock(return_value={})
captured = {}
def fake_queue_notification(n_object, watch, **kwargs):
captured['n_object'] = dict(n_object)
svc = NotificationService(datastore, MagicMock())
svc.queue_notification_for_watch = fake_queue_notification
svc.send_content_changed_notification('test-uuid')
assert captured['n_object']['_llm_intent'] == 'flag price drops'
def test_null_llm_result_when_no_evaluation(self):
"""When LLM wasn't evaluated, _llm_result is None — tokens render as empty."""
from changedetectionio.notification_service import NotificationService
watch = self._make_watch(llm_result=None, llm_intent='')
datastore = MagicMock()
datastore.data = {
'settings': {
'application': {
'active_base_url': 'http://localhost',
'notification_urls': [],
'notification_title': '',
'notification_body': '',
'notification_format': 'text',
'notification_muted': False,
}
},
'watching': {'test-uuid': watch},
}
datastore.get_all_tags_for_watch = MagicMock(return_value={})
captured = {}
def fake_queue_notification(n_object, watch, **kwargs):
captured['n_object'] = dict(n_object)
svc = NotificationService(datastore, MagicMock())
svc.queue_notification_for_watch = fake_queue_notification
svc.send_content_changed_notification('test-uuid')
assert captured['n_object']['_llm_result'] is None
assert captured['n_object']['_llm_intent'] == ''
# ---------------------------------------------------------------------------
# End-to-end: Jinja2 template rendering with llm_summary / llm_intent
# ---------------------------------------------------------------------------
class TestLlmTokenEndToEnd:
"""
Verify that the tokens render correctly through the Jinja2 engine
used for notification bodies.
"""
def test_llm_summary_renders_in_template(self):
from changedetectionio.jinja2_custom import render as jinja_render
from changedetectionio.notification_service import NotificationContextData
n = NotificationContextData()
n['llm_summary'] = 'Price dropped from $500 to $400'
n['watch_url'] = 'https://example.com'
rendered = jinja_render(
template_str='Change at {{watch_url}}: {{llm_summary}}',
**n
)
assert 'Price dropped from $500 to $400' in rendered
assert 'https://example.com' in rendered
def test_llm_intent_renders_in_template(self):
from changedetectionio.jinja2_custom import render as jinja_render
from changedetectionio.notification_service import NotificationContextData
n = NotificationContextData()
n['llm_intent'] = 'flag price drops below $300'
n['watch_url'] = 'https://example.com'
rendered = jinja_render(
template_str='Intent was: {{llm_intent}}',
**n
)
assert 'flag price drops below $300' in rendered
def test_llm_summary_empty_string_when_none(self):
from changedetectionio.jinja2_custom import render as jinja_render
from changedetectionio.notification_service import NotificationContextData
n = NotificationContextData()
# llm_summary defaults to None in NotificationContextData
rendered = jinja_render(
template_str='Summary: {{llm_summary or ""}}',
**n
)
assert rendered == 'Summary: '
def test_both_tokens_in_same_template(self):
from changedetectionio.jinja2_custom import render as jinja_render
from changedetectionio.notification_service import NotificationContextData
n = NotificationContextData()
n['llm_summary'] = 'New senior role posted'
n['llm_intent'] = 'alert on new engineering jobs'
n['watch_url'] = 'https://jobs.example.com'
rendered = jinja_render(
template_str='[{{llm_intent}}] {{llm_summary}} — {{watch_url}}',
**n
)
assert 'alert on new engineering jobs' in rendered
assert 'New senior role posted' in rendered
assert 'https://jobs.example.com' in rendered

View File

@@ -0,0 +1,137 @@
"""
Unit tests for changedetectionio/llm/prompt_builder.py
All functions are pure — no external dependencies needed.
"""
import pytest
from changedetectionio.llm.prompt_builder import (
build_eval_prompt,
build_eval_system_prompt,
build_setup_prompt,
build_setup_system_prompt,
SNAPSHOT_CONTEXT_CHARS,
)
class TestBuildEvalPrompt:
def test_contains_intent(self):
prompt = build_eval_prompt(intent='Alert on price drops', diff='- $500\n+ $400')
assert 'Alert on price drops' in prompt
def test_contains_diff(self):
prompt = build_eval_prompt(intent='price', diff='- $500\n+ $400')
assert '- $500' in prompt
assert '+ $400' in prompt
def test_optional_url_included_when_provided(self):
prompt = build_eval_prompt(
intent='price',
diff='some diff',
url='https://example.com/product',
)
assert 'https://example.com/product' in prompt
def test_url_absent_when_not_provided(self):
prompt = build_eval_prompt(intent='price', diff='diff')
assert 'URL:' not in prompt
def test_optional_title_included_when_provided(self):
prompt = build_eval_prompt(
intent='price',
diff='diff',
title='Example Product Page',
)
assert 'Example Product Page' in prompt
def test_snapshot_context_included(self):
snapshot = 'Current price: $400. Stock: in stock. Description: widget.'
prompt = build_eval_prompt(
intent='price',
diff='- $500\n+ $400',
current_snapshot=snapshot,
)
# Snapshot excerpt should appear somewhere in the prompt
assert 'Current price' in prompt or '$400' in prompt
def test_large_snapshot_trimmed_to_budget(self):
# Snapshot larger than SNAPSHOT_CONTEXT_CHARS should be trimmed
large_snapshot = 'irrelevant content line\n' * 2000
prompt = build_eval_prompt(
intent='price drop',
diff='changed',
current_snapshot=large_snapshot,
)
# Prompt should not be astronomically large
assert len(prompt) < len(large_snapshot)
def test_empty_snapshot_skipped(self):
prompt_with = build_eval_prompt(intent='x', diff='d', current_snapshot='some text')
prompt_without = build_eval_prompt(intent='x', diff='d', current_snapshot='')
# Without snapshot should be shorter
assert len(prompt_without) < len(prompt_with)
class TestBuildEvalSystemPrompt:
def test_returns_string(self):
result = build_eval_system_prompt()
assert isinstance(result, str)
assert len(result) > 0
def test_instructs_json_only_output(self):
result = build_eval_system_prompt()
assert 'JSON' in result or 'json' in result.lower()
def test_defines_important_field(self):
result = build_eval_system_prompt()
assert 'important' in result
def test_defines_summary_field(self):
result = build_eval_system_prompt()
assert 'summary' in result
class TestBuildSetupPrompt:
def test_contains_intent(self):
prompt = build_setup_prompt(
intent='monitor footer changes',
snapshot_text='<footer>Copyright 2024</footer>',
)
assert 'monitor footer changes' in prompt
def test_contains_url_when_provided(self):
prompt = build_setup_prompt(
intent='price',
snapshot_text='price: $10',
url='https://shop.example.com',
)
assert 'https://shop.example.com' in prompt
def test_url_absent_when_not_provided(self):
prompt = build_setup_prompt(intent='price', snapshot_text='text')
assert 'URL:' not in prompt
def test_large_snapshot_trimmed(self):
big_snapshot = 'unrelated junk line\n' * 500
prompt = build_setup_prompt(
intent='monitor price section',
snapshot_text=big_snapshot,
)
assert len(prompt) < len(big_snapshot)
class TestBuildSetupSystemPrompt:
def test_returns_string(self):
result = build_setup_system_prompt()
assert isinstance(result, str)
def test_forbids_positional_selectors(self):
result = build_setup_system_prompt()
assert 'nth-child' in result or 'positional' in result
def test_defines_needs_prefilter_field(self):
result = build_setup_system_prompt()
assert 'needs_prefilter' in result
def test_defines_selector_field(self):
result = build_setup_system_prompt()
assert 'selector' in result

View File

@@ -0,0 +1,146 @@
"""
Unit tests for changedetectionio/llm/response_parser.py
All functions are pure — no external dependencies needed.
"""
import pytest
from changedetectionio.llm.response_parser import (
_extract_json,
parse_eval_response,
parse_setup_response,
)
class TestExtractJson:
def test_plain_json_passes_through(self):
raw = '{"important": true, "summary": "price dropped"}'
assert _extract_json(raw) == raw
def test_strips_json_code_fence(self):
raw = '```json\n{"important": false, "summary": "no match"}\n```'
result = _extract_json(raw)
assert result.startswith('{')
assert '"important"' in result
def test_strips_plain_code_fence(self):
raw = '```\n{"important": true, "summary": "ok"}\n```'
result = _extract_json(raw)
assert result.startswith('{')
def test_extracts_json_from_surrounding_text(self):
raw = 'Here is my response: {"important": true, "summary": "match"} — done.'
result = _extract_json(raw)
assert result == '{"important": true, "summary": "match"}'
def test_multiline_json(self):
raw = '{\n "important": false,\n "summary": "nothing relevant"\n}'
result = _extract_json(raw)
assert '"important"' in result
class TestParseEvalResponse:
def test_valid_important_true(self):
raw = '{"important": true, "summary": "Price dropped from $500 to $400"}'
result = parse_eval_response(raw)
assert result['important'] is True
assert result['summary'] == 'Price dropped from $500 to $400'
def test_valid_important_false(self):
raw = '{"important": false, "summary": "Only a date counter changed"}'
result = parse_eval_response(raw)
assert result['important'] is False
assert 'date counter' in result['summary']
def test_markdown_fenced_response(self):
raw = '```json\n{"important": true, "summary": "New job posted"}\n```'
result = parse_eval_response(raw)
assert result['important'] is True
assert result['summary'] == 'New job posted'
def test_malformed_json_falls_back_to_safe_default(self):
result = parse_eval_response('this is not json at all')
assert result['important'] is False
assert result['summary'] == ''
def test_empty_string_falls_back(self):
result = parse_eval_response('')
assert result['important'] is False
def test_truthy_integer_coerced_to_bool(self):
raw = '{"important": 1, "summary": "yes"}'
result = parse_eval_response(raw)
assert result['important'] is True
def test_summary_stripped_of_whitespace(self):
raw = '{"important": false, "summary": " no match "}'
result = parse_eval_response(raw)
assert result['summary'] == 'no match'
def test_missing_summary_defaults_to_empty_string(self):
raw = '{"important": true}'
result = parse_eval_response(raw)
assert result['summary'] == ''
def test_extra_keys_ignored(self):
raw = '{"important": false, "summary": "skip", "confidence": 0.3, "debug": "xyz"}'
result = parse_eval_response(raw)
assert result['important'] is False
assert result['summary'] == 'skip'
class TestParseSetupResponse:
def test_no_prefilter_needed(self):
raw = '{"needs_prefilter": false, "selector": null, "reason": "intent is global"}'
result = parse_setup_response(raw)
assert result['needs_prefilter'] is False
assert result['selector'] is None
def test_semantic_selector_accepted(self):
raw = '{"needs_prefilter": true, "selector": "footer", "reason": "intent references footer"}'
result = parse_setup_response(raw)
assert result['needs_prefilter'] is True
assert result['selector'] == 'footer'
def test_attribute_selector_accepted(self):
raw = '{"needs_prefilter": true, "selector": "[class*=\'price\']", "reason": "pricing section"}'
result = parse_setup_response(raw)
assert result['needs_prefilter'] is True
assert result['selector'] is not None
def test_nth_child_positional_selector_rejected(self):
raw = '{"needs_prefilter": true, "selector": "div:nth-child(3)", "reason": "third div"}'
result = parse_setup_response(raw)
assert result['selector'] is None
assert result['needs_prefilter'] is False
def test_nth_of_type_positional_selector_rejected(self):
raw = '{"needs_prefilter": true, "selector": "p:nth-of-type(2)", "reason": "second p"}'
result = parse_setup_response(raw)
assert result['selector'] is None
assert result['needs_prefilter'] is False
def test_eq_positional_selector_rejected(self):
raw = '{"needs_prefilter": true, "selector": "div:eq(0)", "reason": "first div"}'
result = parse_setup_response(raw)
assert result['selector'] is None
def test_xpath_positional_selector_rejected(self):
raw = '{"needs_prefilter": true, "selector": "//*[2]", "reason": "second element"}'
result = parse_setup_response(raw)
assert result['selector'] is None
def test_selector_forced_to_null_when_needs_prefilter_false(self):
# Even if selector is provided alongside needs_prefilter=false, selector is nulled
raw = '{"needs_prefilter": false, "selector": "main", "reason": "not needed"}'
result = parse_setup_response(raw)
assert result['selector'] is None
def test_malformed_json_safe_defaults(self):
result = parse_setup_response('garbage text')
assert result['needs_prefilter'] is False
assert result['selector'] is None
assert result['reason'] == ''
def test_empty_response_safe_defaults(self):
result = parse_setup_response('')
assert result['needs_prefilter'] is False

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Integration tests for AI Change Summary:
- llm_change_summary field saved via watch edit form
- llm_change_summary cascades from tag to watches
- {{ diff }} replaced by AI summary in notifications
- {{ raw_diff }} always contains original diff
- summarise_change only runs when change is detected
"""
import json
import time
from unittest.mock import patch
from flask import url_for
from changedetectionio.tests.util import wait_for_all_checks, delete_all_watches
HTML_V1 = "<html><body><ul><li>Item A</li><li>Item B</li></ul></body></html>"
HTML_V2 = "<html><body><ul><li>Item A</li><li>Item B</li><li>Item C — NEW</li></ul></body></html>"
def _set_response(datastore_path, content):
import os
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(content)
def _configure_llm(client):
ds = client.application.config.get('DATASTORE')
ds.data['settings']['application']['llm'] = {'model': 'gpt-4o-mini', 'api_key': 'sk-test'}
# ---------------------------------------------------------------------------
# Form field persistence
# ---------------------------------------------------------------------------
def test_llm_change_summary_saved_via_edit_form(
client, live_server, measure_memory_usage, datastore_path):
"""llm_change_summary submitted via watch edit form is persisted."""
_set_response(datastore_path, HTML_V1)
_configure_llm(client)
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={
"url": test_url,
"fetch_backend": "html_requests",
"time_between_check_use_default": "y",
"llm_change_summary": "List new items added as bullet points. Translate to English.",
},
follow_redirects=True,
)
assert b"Updated watch." in res.data
watch = client.application.config.get('DATASTORE').data['watching'][uuid]
assert watch.get('llm_change_summary') == "List new items added as bullet points. Translate to English."
delete_all_watches(client)
def test_llm_change_summary_cascades_from_tag(
client, live_server, measure_memory_usage, datastore_path):
"""llm_change_summary set on a tag is resolved for watches in that tag."""
from changedetectionio.llm.evaluator import resolve_llm_field
ds = client.application.config.get('DATASTORE')
_configure_llm(client)
_set_response(datastore_path, HTML_V1)
test_url = url_for('test_endpoint', _external=True)
# Create a tag with llm_change_summary
tag_uuid = ds.add_tag('events-group')
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Summarise new events'
# Watch in that tag, no own summary prompt
uuid = ds.add_watch(url=test_url)
ds.data['watching'][uuid]['tags'] = [tag_uuid]
ds.data['watching'][uuid]['llm_change_summary'] = ''
watch = ds.data['watching'][uuid]
value, source = resolve_llm_field(watch, ds, 'llm_change_summary')
assert value == 'Summarise new events'
assert source == 'events-group'
delete_all_watches(client)
# ---------------------------------------------------------------------------
# Notification token behaviour
# ---------------------------------------------------------------------------
def test_diff_token_replaced_by_ai_summary_in_notification(
client, live_server, measure_memory_usage, datastore_path):
"""
When _llm_change_summary is set on the watch, the notification handler
must substitute it into {{ diff }} and preserve {{ raw_diff }}.
"""
from changedetectionio.notification.handler import process_notification
n_object = {
'notification_urls': ['json://localhost/'],
'notification_title': 'Change detected',
'notification_body': 'Summary: {{diff}}\nRaw: {{raw_diff}}',
'notification_format': 'text',
'uuid': 'test-uuid',
'watch_url': 'https://example.com',
'current_snapshot': 'Item A\nItem B\nItem C',
'prev_snapshot': 'Item A\nItem B',
'diff': '', # populated by add_rendered_diff_to_notification_vars
'raw_diff': '',
'_llm_change_summary': '1 new item added: Item C',
'_llm_result': None,
'_llm_intent': '',
'base_url': 'http://localhost:5000/',
'watch_mime_type': 'text/plain',
'triggered_text': '',
}
# We only need to verify the token substitution logic, not send a real notification
# Invoke just enough of the handler to check n_object state after substitution
from changedetectionio.notification_service import add_rendered_diff_to_notification_vars
diff_vars = add_rendered_diff_to_notification_vars(
notification_scan_text=n_object['notification_body'] + n_object['notification_title'],
current_snapshot=n_object['current_snapshot'],
prev_snapshot=n_object['prev_snapshot'],
word_diff=False,
)
n_object.update(diff_vars)
# Simulate what handler.py does
n_object['raw_diff'] = n_object.get('diff', '')
llm_summary = (n_object.get('_llm_change_summary') or '').strip()
if llm_summary:
n_object['diff'] = llm_summary
assert n_object['diff'] == '1 new item added: Item C'
assert 'Item C' in n_object['raw_diff'] or n_object['raw_diff'] != n_object['diff']
delete_all_watches(client)
def test_diff_token_unchanged_when_no_ai_summary(
client, live_server, measure_memory_usage, datastore_path):
"""When no AI Change Summary is configured, {{ diff }} renders the raw diff as normal."""
from changedetectionio.notification_service import add_rendered_diff_to_notification_vars
n_object = {
'current_snapshot': 'Item A\nItem B\nItem C',
'prev_snapshot': 'Item A\nItem B',
'_llm_change_summary': '',
}
diff_vars = add_rendered_diff_to_notification_vars(
notification_scan_text='{{diff}}',
current_snapshot=n_object['current_snapshot'],
prev_snapshot=n_object['prev_snapshot'],
word_diff=False,
)
n_object.update(diff_vars)
raw = n_object.get('diff', '')
n_object['raw_diff'] = raw
if (n_object.get('_llm_change_summary') or '').strip():
n_object['diff'] = n_object['_llm_change_summary']
# diff should still be the raw diff (not replaced)
assert n_object['diff'] == n_object['raw_diff']
delete_all_watches(client)

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
Integration tests: /edit/<uuid>/preview-rendered returns llm_evaluation when
llm_intent is submitted alongside the filter form data.
These tests verify the full backend path:
JS POSTs llm_intent → prepare_filter_prevew() applies it to tmp_watch
→ preview_extract() is called → llm_evaluation appears in JSON response
The response uses {'found': bool, 'answer': str} — NOT the diff-evaluation
{'important', 'summary'} shape, because preview asks the LLM to extract from
the current content directly (e.g. "30 articles listed") rather than compare
a diff.
"""
import json
import time
from unittest.mock import patch
from flask import url_for
from changedetectionio.tests.util import wait_for_all_checks, delete_all_watches
HTML_WITH_ARTICLES = """<html><body>
<ul id="articles">
<li>Article One</li>
<li>Article Two</li>
<li>Article Three</li>
</ul>
</body></html>"""
HTML_WITH_PRICE = """<html><body>
<p class="price">Original price: $199.00</p>
<p class="discount">Now: $149.00 — 25% off!</p>
</body></html>"""
def _set_response(datastore_path, content):
import os
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(content)
def _add_and_fetch(client, live_server, datastore_path, html):
"""Add a watch, fetch it once so a snapshot exists, return uuid."""
_set_response(datastore_path, html)
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
time.sleep(0.5)
wait_for_all_checks(client)
return uuid
def _configure_llm(client):
"""Put a fake LLM config into the datastore."""
datastore = client.application.config.get('DATASTORE')
datastore.data['settings']['application']['llm'] = {
'model': 'gpt-4o-mini',
'api_key': 'sk-test-fake',
}
# ---------------------------------------------------------------------------
# llm_intent submitted → llm_evaluation returned with found/answer shape
# ---------------------------------------------------------------------------
def test_preview_returns_llm_answer_for_article_intent(
client, live_server, measure_memory_usage, datastore_path):
"""
With llm_intent='Tell me the number of articles in the list',
the preview endpoint returns llm_evaluation with found=True and an answer
that directly addresses the intent (e.g. "3 articles listed").
"""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_ARTICLES)
_configure_llm(client)
llm_json = '{"found": true, "answer": "3 articles are listed in the content"}'
with patch('changedetectionio.llm.client.completion', return_value=(llm_json, 50)):
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
'llm_intent': 'Tell me the number of articles in the list',
'fetch_backend': 'html_requests',
},
)
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
# Filtered text must still be present
assert data.get('after_filter'), "after_filter must be present"
# LLM evaluation must be returned with the new shape
ev = data.get('llm_evaluation')
assert ev is not None, "llm_evaluation must be in response"
assert ev['found'] is True
assert '3' in ev['answer']
delete_all_watches(client)
def test_preview_returns_llm_answer_for_price_intent(
client, live_server, measure_memory_usage, datastore_path):
"""
With a price-change intent, the LLM answer should reflect the discount
extracted directly from the current page (not a diff comparison).
"""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_PRICE)
_configure_llm(client)
llm_json = '{"found": true, "answer": "Price $149, 25% off (was $199)"}'
with patch('changedetectionio.llm.client.completion', return_value=(llm_json, 60)):
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
'llm_intent': 'Flag any price change, including discount percentages',
'fetch_backend': 'html_requests',
},
)
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
ev = data.get('llm_evaluation')
assert ev is not None
assert ev['found'] is True
assert '25' in ev['answer'] or '149' in ev['answer']
delete_all_watches(client)
def test_preview_found_false_when_content_not_relevant(
client, live_server, measure_memory_usage, datastore_path):
"""found=False when the LLM determines page content doesn't match intent."""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_ARTICLES)
_configure_llm(client)
llm_json = '{"found": false, "answer": "No price information found on this page"}'
with patch('changedetectionio.llm.client.completion', return_value=(llm_json, 45)):
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
'llm_intent': 'Show me any product prices',
'fetch_backend': 'html_requests',
},
)
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
ev = data.get('llm_evaluation')
assert ev is not None
assert ev['found'] is False
assert ev['answer']
delete_all_watches(client)
# ---------------------------------------------------------------------------
# No intent / no LLM → llm_evaluation is None
# ---------------------------------------------------------------------------
def test_preview_no_llm_evaluation_without_intent(
client, live_server, measure_memory_usage, datastore_path):
"""When llm_intent is absent, the LLM client must not be called."""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_ARTICLES)
_configure_llm(client)
with patch('changedetectionio.llm.client.completion') as mock_llm:
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={'fetch_backend': 'html_requests'},
)
mock_llm.assert_not_called()
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
assert data.get('llm_evaluation') is None
delete_all_watches(client)
def test_preview_no_llm_evaluation_when_llm_not_configured(
client, live_server, measure_memory_usage, datastore_path):
"""When LLM model is not set, llm_evaluation must be None even with an intent."""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_ARTICLES)
# Intentionally do NOT configure LLM
with patch('changedetectionio.llm.client.completion') as mock_llm:
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
'llm_intent': 'Tell me the number of articles',
'fetch_backend': 'html_requests',
},
)
mock_llm.assert_not_called()
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
assert data.get('llm_evaluation') is None
delete_all_watches(client)
# ---------------------------------------------------------------------------
# LLM failure → llm_evaluation is None, preview still works
# ---------------------------------------------------------------------------
def test_preview_llm_failure_does_not_break_preview(
client, live_server, measure_memory_usage, datastore_path):
"""If the LLM call raises, preview_extract returns None — preview still works."""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_ARTICLES)
_configure_llm(client)
with patch('changedetectionio.llm.client.completion', side_effect=Exception('API timeout')):
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
'llm_intent': 'Tell me the number of articles',
'fetch_backend': 'html_requests',
},
)
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
# Filter content must still be returned
assert data.get('after_filter')
# preview_extract returns None on error (doesn't fail-open like evaluate_change)
assert data.get('llm_evaluation') is None
delete_all_watches(client)

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
# run from dir above changedetectionio/ dir
# python3 -m unittest changedetectionio.tests.unit.test_jq_security
import unittest
class TestJqExpressionSecurity(unittest.TestCase):
def test_blocked_builtins_raise(self):
"""Each dangerous builtin must be rejected by validate_jq_expression."""
from changedetectionio.html_tools import validate_jq_expression
blocked = [
# env access
'env',
'.foo | env',
'$ENV',
'$ENV.SECRET',
# file read via module system
'include "foo"',
'import "foo" as f',
# stdin reads
'input',
'inputs',
'[.,inputs]',
# process termination
'halt',
'halt_error(1)',
# stderr/debug leakage
'debug',
'. | debug | .foo',
'stderr',
# misc info leakage
'$__loc__',
'builtins',
'modulemeta',
'$JQ_BUILD_CONFIGURATION',
]
for expr in blocked:
with self.assertRaises(ValueError, msg=f"Expected ValueError for: {expr!r}"):
validate_jq_expression(expr)
def test_safe_expressions_pass(self):
"""Normal jq expressions must not be blocked."""
from changedetectionio.html_tools import validate_jq_expression
safe = [
'.foo',
'.items[] | .price',
'map(select(.active)) | length',
'.[] | select(.name | test("foo"))',
'to_entries | map(.value) | add',
'[.[] | .id] | unique',
'.price | tonumber',
'if .stock > 0 then "in stock" else "out of stock" end',
]
for expr in safe:
try:
validate_jq_expression(expr)
except ValueError as e:
self.fail(f"validate_jq_expression raised ValueError for safe expression {expr!r}: {e}")
def test_allow_risky_env_var_bypasses_check(self):
"""JQ_ALLOW_RISKY_EXPRESSIONS=true must skip all blocking."""
import os
from unittest.mock import patch
from changedetectionio.html_tools import validate_jq_expression
with patch.dict(os.environ, {'JQ_ALLOW_RISKY_EXPRESSIONS': 'true'}):
# Should not raise even for the most dangerous expression
try:
validate_jq_expression('env')
validate_jq_expression('$ENV')
except ValueError as e:
self.fail(f"Should not block when JQ_ALLOW_RISKY_EXPRESSIONS=true: {e}")
def test_allow_risky_env_var_off_by_default(self):
"""Without JQ_ALLOW_RISKY_EXPRESSIONS set, blocking must be active."""
import os
from unittest.mock import patch
from changedetectionio.html_tools import validate_jq_expression
env = {k: v for k, v in os.environ.items() if k != 'JQ_ALLOW_RISKY_EXPRESSIONS'}
with patch.dict(os.environ, env, clear=True):
with self.assertRaises(ValueError):
validate_jq_expression('env')
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,127 @@
"""
Unit tests for SSRF protection in the Apprise custom HTTP notification handler.
The handler (notification/apprise_plugin/custom_handlers.py) must block requests
to private/IANA-reserved addresses unless ALLOW_IANA_RESTRICTED_ADDRESSES=true.
"""
import pytest
from unittest.mock import patch, MagicMock
def _make_meta(url: str) -> dict:
"""Build a minimal Apprise meta dict that apprise_http_custom_handler expects."""
from apprise.utils.parse import parse_url as apprise_parse_url
schema = url.split("://")[0]
parsed = apprise_parse_url(url, default_schema=schema, verify_host=False, simple=True)
parsed["url"] = url
parsed["schema"] = schema
return parsed
class TestNotificationSSRFProtection:
def test_private_ip_blocked_by_default(self):
"""Requests to private IP addresses must be blocked when ALLOW_IANA_RESTRICTED_ADDRESSES is unset."""
from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler
meta = _make_meta("post://192.168.1.100/webhook")
with patch("changedetectionio.notification.apprise_plugin.custom_handlers.is_private_hostname", return_value=True), \
patch.dict("os.environ", {}, clear=False):
# Remove the env var if present so the default 'false' applies
import os
os.environ.pop("ALLOW_IANA_RESTRICTED_ADDRESSES", None)
with pytest.raises(ValueError, match="ALLOW_IANA_RESTRICTED_ADDRESSES"):
apprise_http_custom_handler(
body="test body",
title="test title",
notify_type="info",
meta=meta,
)
def test_loopback_blocked_by_default(self):
"""Requests to loopback addresses (127.x.x.x) must be blocked."""
from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler
meta = _make_meta("post://127.0.0.1:8080/internal")
with patch("changedetectionio.notification.apprise_plugin.custom_handlers.is_private_hostname", return_value=True):
import os
os.environ.pop("ALLOW_IANA_RESTRICTED_ADDRESSES", None)
with pytest.raises(ValueError, match="ALLOW_IANA_RESTRICTED_ADDRESSES"):
apprise_http_custom_handler(
body="test body",
title="test title",
notify_type="info",
meta=meta,
)
def test_private_ip_allowed_when_env_var_set(self):
"""When ALLOW_IANA_RESTRICTED_ADDRESSES=true, requests to private IPs must go through."""
from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler
meta = _make_meta("post://192.168.1.100/webhook")
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
with patch("changedetectionio.notification.apprise_plugin.custom_handlers.is_private_hostname", return_value=True), \
patch("changedetectionio.notification.apprise_plugin.custom_handlers.requests.request", return_value=mock_response) as mock_req, \
patch.dict("os.environ", {"ALLOW_IANA_RESTRICTED_ADDRESSES": "true"}):
result = apprise_http_custom_handler(
body="test body",
title="test title",
notify_type="info",
meta=meta,
)
assert result is True
mock_req.assert_called_once()
def test_public_hostname_not_blocked(self):
"""Public hostnames must not be blocked by the SSRF guard."""
from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler
meta = _make_meta("post://example.com/webhook")
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
with patch("changedetectionio.notification.apprise_plugin.custom_handlers.is_private_hostname", return_value=False), \
patch("changedetectionio.notification.apprise_plugin.custom_handlers.requests.request", return_value=mock_response) as mock_req:
import os
os.environ.pop("ALLOW_IANA_RESTRICTED_ADDRESSES", None)
result = apprise_http_custom_handler(
body="test body",
title="test title",
notify_type="info",
meta=meta,
)
assert result is True
mock_req.assert_called_once()
def test_error_message_contains_env_var_hint(self):
"""The ValueError message must include the ALLOW_IANA_RESTRICTED_ADDRESSES hint."""
from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler
meta = _make_meta("post://10.0.0.1/api")
with patch("changedetectionio.notification.apprise_plugin.custom_handlers.is_private_hostname", return_value=True):
import os
os.environ.pop("ALLOW_IANA_RESTRICTED_ADDRESSES", None)
with pytest.raises(ValueError) as exc_info:
apprise_http_custom_handler(
body="test",
title="test",
notify_type="info",
meta=meta,
)
assert "ALLOW_IANA_RESTRICTED_ADDRESSES=true" in str(exc_info.value)

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: cs\n"
@@ -590,15 +590,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
@@ -829,26 +829,6 @@ msgstr ""
msgid "Updated"
msgstr "Ztlumit"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "Filtry a spouštěče"
@@ -863,6 +843,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -948,11 +932,7 @@ msgstr "Tag / Název štítku"
msgid "No website organisational tags/groups configured"
msgstr "Žádné skupiny/značky zatím nebyly nastaveny"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Ztlumit oznámení"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "Upravit"
@@ -1155,14 +1135,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr ""
@@ -1175,10 +1147,6 @@ msgstr "Sledování aktualizováno."
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr ""
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr ""
@@ -1288,17 +1256,14 @@ msgid "Jump"
msgstr "Skok"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "Text chyby"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "Snímek obrazovky s chybou"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "Text"
@@ -1306,8 +1271,7 @@ msgstr "Text"
msgid "Current screenshot"
msgstr "Aktuální snímek obrazovky"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "Extrahovat data"
@@ -1897,26 +1861,6 @@ msgstr "Nejsou nakonfigurována žádná sledování webových stránek, do vý
msgid "import a list"
msgstr "importovat seznam"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "Kontrola zásob a ceny"
@@ -1946,7 +1890,6 @@ msgid "Queued"
msgstr "Ve frontě"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "Historie"
@@ -1980,168 +1923,6 @@ msgstr "Znovu zkontrolovat vše"
msgid "in '%(title)s'"
msgstr "v '%(title)s'"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2426,26 +2207,10 @@ msgstr "Možnosti uživatelského rozhraní"
msgid "Selector"
msgstr "Režim výběru:"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "Pauza"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "Interval mezi kontrolami"
@@ -2534,8 +2299,7 @@ msgstr "Blokovat detekci změn, když se text shoduje"
msgid "Execute JavaScript before change detection"
msgstr "Spusťte JavaScript před detekcí změn"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "Uložit"
@@ -2603,7 +2367,7 @@ msgstr "Neplatná syntaxe šablony: %(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr "Neplatná syntax šablony v \"%(header)s\" hlavička: %(error)s"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "Název"
@@ -2699,10 +2463,6 @@ msgstr "Text chyby"
msgid "Ignore whitespace"
msgstr "Ignorujte mezery"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "Musí být mezi 0 a 100"
@@ -2902,42 +2662,6 @@ msgstr "Doplnění zásob a zjištění ceny pro stránky s JEDINÝM produktem"
msgid "Detects if the product goes back to in-stock"
msgstr "Zjistí, zda se produkt vrátí na sklad"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "Změny textu webové stránky/HTML, JSON a PDF"
@@ -3117,7 +2841,7 @@ msgstr ""
msgid "Use"
msgstr "Použít"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "Zobrazit pokročilou nápovědu a tipy"
@@ -3221,26 +2945,6 @@ msgstr ""
msgid "Format for all notifications"
msgstr "Formát pro všechna oznámení"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr ""
@@ -3357,18 +3061,10 @@ msgstr "Nedojde k detekci změn, protože tento text existuje."
msgid "Blocked text"
msgstr "Blokovaný text"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "Vyhledejte nebo použijte klávesu Alt+S"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "Aktualizace v reálném čase offline"
@@ -3539,6 +3235,10 @@ msgstr ""
msgid "Unmute notifications"
msgstr "Odtlumit oznámení"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Ztlumit oznámení"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "Oznámení jsou ztlumena - klikněte pro odtlumení"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-14 03:57+0100\n"
"Last-Translator: \n"
"Language: de\n"
@@ -604,15 +604,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
@@ -843,26 +843,6 @@ msgstr "Tag nicht gefunden"
msgid "Updated"
msgstr "Aktualisiert"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "Filter und Trigger"
@@ -877,6 +857,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -964,11 +948,7 @@ msgstr "Tag-/Labelname"
msgid "No website organisational tags/groups configured"
msgstr "Keine Gruppen/Labels konfiguriert"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Benachrichtigungen stummschalten"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "Bearbeiten"
@@ -1175,14 +1155,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr "Aktualisierte Überwachung fortgesetzt!"
@@ -1195,10 +1167,6 @@ msgstr "Überwachung aktualisiert."
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr "Vorschau nicht verfügbar Kein Abruf/keine Überprüfung abgeschlossen oder Trigger nicht erreicht"
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr ""
@@ -1310,17 +1278,14 @@ msgid "Jump"
msgstr "Springen"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "Fehlertext"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "Fehler-Screenshot"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "Text"
@@ -1328,8 +1293,7 @@ msgstr "Text"
msgid "Current screenshot"
msgstr "Aktueller Screenshot"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "Daten extrahieren"
@@ -1943,26 +1907,6 @@ msgstr "Es sind keine Website-Überwachungen konfiguriert. Bitte fügen Sie im F
msgid "import a list"
msgstr "eine Liste importieren"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "Erkennen von Lagerbeständen und Preisen"
@@ -1992,7 +1936,6 @@ msgid "Queued"
msgstr "Wartend"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "Verlauf"
@@ -2026,168 +1969,6 @@ msgstr "Überprüfen Sie alles noch einmal"
msgid "in '%(title)s'"
msgstr "in '%(title)s'"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2472,26 +2253,10 @@ msgstr "UI-Optionen"
msgid "Selector"
msgstr "Auswahlmodus:"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "Wert"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "Prüfintervall"
@@ -2580,8 +2345,7 @@ msgstr "Blockieren Sie die Änderungserkennung, während der Text übereinstimmt
msgid "Execute JavaScript before change detection"
msgstr "Führen Sie JavaScript vor der Änderungserkennung aus"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "Speichern"
@@ -2650,7 +2414,7 @@ msgstr "Ungültige Vorlagensyntax: %(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr "Ungültige Vorlagensyntax im Header „%(header)s“: %(error)s"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "Name"
@@ -2746,10 +2510,6 @@ msgstr "Text ignorieren"
msgid "Ignore whitespace"
msgstr "Leerzeichen ignorieren"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "Muss zwischen 0 und 100 liegen"
@@ -2951,42 +2711,6 @@ msgstr "Wiederauffüllung und Preiserkennung für Seiten mit einem EINZELNEN Pro
msgid "Detects if the product goes back to in-stock"
msgstr "Erkennt, ob das Produkt wieder auf Lager ist"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "Änderungen an Webseitentext/HTML, JSON und PDF"
@@ -3166,7 +2890,7 @@ msgstr ""
msgid "Use"
msgstr "Verwenden"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "Erweiterte Hilfe und Tipps anzeigen"
@@ -3270,26 +2994,6 @@ msgstr ""
msgid "Format for all notifications"
msgstr "Format für alle Benachrichtigungen"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr ""
@@ -3406,18 +3110,10 @@ msgstr "Es wird keine Änderungserkennung stattfinden, da dieser Text existiert.
msgid "Blocked text"
msgstr "Blockierter Text"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "Suchen oder Alt+S-Taste verwenden"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "Echtzeit-Updates offline"
@@ -3590,6 +3286,10 @@ msgstr ""
msgid "Unmute notifications"
msgstr "Benachrichtigungen entstummen"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Benachrichtigungen stummschalten"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "Benachrichtigungen sind stummgeschaltet - klicken zum Entstummen"

View File

@@ -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-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-12 16:33+0100\n"
"Last-Translator: British English Translation Team\n"
"Language: en_GB\n"
@@ -590,15 +590,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
@@ -829,26 +829,6 @@ msgstr ""
msgid "Updated"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr ""
@@ -863,6 +843,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -948,11 +932,7 @@ msgstr ""
msgid "No website organisational tags/groups configured"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr ""
@@ -1155,14 +1135,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr ""
@@ -1175,10 +1147,6 @@ msgstr ""
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr ""
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr ""
@@ -1288,17 +1256,14 @@ msgid "Jump"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr ""
@@ -1306,8 +1271,7 @@ msgstr ""
msgid "Current screenshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr ""
@@ -1897,26 +1861,6 @@ msgstr ""
msgid "import a list"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr ""
@@ -1946,7 +1890,6 @@ msgid "Queued"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr ""
@@ -1980,168 +1923,6 @@ msgstr ""
msgid "in '%(title)s'"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2424,26 +2205,10 @@ msgstr ""
msgid "Selector"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr ""
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr ""
@@ -2532,8 +2297,7 @@ msgstr ""
msgid "Execute JavaScript before change detection"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr ""
@@ -2601,7 +2365,7 @@ msgstr ""
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr ""
@@ -2697,10 +2461,6 @@ msgstr ""
msgid "Ignore whitespace"
msgstr ""
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr ""
@@ -2900,42 +2660,6 @@ msgstr ""
msgid "Detects if the product goes back to in-stock"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr ""
@@ -3115,7 +2839,7 @@ msgstr ""
msgid "Use"
msgstr ""
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr ""
@@ -3219,26 +2943,6 @@ msgstr ""
msgid "Format for all notifications"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr ""
@@ -3355,18 +3059,10 @@ msgstr ""
msgid "Blocked text"
msgstr ""
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr ""
@@ -3537,6 +3233,10 @@ msgstr ""
msgid "Unmute notifications"
msgstr ""
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr ""
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr ""

View File

@@ -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-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-12 16:37+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en_US\n"
@@ -590,15 +590,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
@@ -829,26 +829,6 @@ msgstr ""
msgid "Updated"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr ""
@@ -863,6 +843,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -948,11 +932,7 @@ msgstr ""
msgid "No website organisational tags/groups configured"
msgstr "No website organizational tags/groups configured"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr ""
@@ -1155,14 +1135,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr ""
@@ -1175,10 +1147,6 @@ msgstr ""
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr ""
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr ""
@@ -1288,17 +1256,14 @@ msgid "Jump"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr ""
@@ -1306,8 +1271,7 @@ msgstr ""
msgid "Current screenshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr ""
@@ -1897,26 +1861,6 @@ msgstr ""
msgid "import a list"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr ""
@@ -1946,7 +1890,6 @@ msgid "Queued"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr ""
@@ -1980,168 +1923,6 @@ msgstr ""
msgid "in '%(title)s'"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2424,26 +2205,10 @@ msgstr ""
msgid "Selector"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr ""
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr ""
@@ -2532,8 +2297,7 @@ msgstr ""
msgid "Execute JavaScript before change detection"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr ""
@@ -2601,7 +2365,7 @@ msgstr ""
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr ""
@@ -2697,10 +2461,6 @@ msgstr ""
msgid "Ignore whitespace"
msgstr ""
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr ""
@@ -2900,42 +2660,6 @@ msgstr ""
msgid "Detects if the product goes back to in-stock"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr ""
@@ -3115,7 +2839,7 @@ msgstr ""
msgid "Use"
msgstr ""
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr ""
@@ -3219,26 +2943,6 @@ msgstr ""
msgid "Format for all notifications"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr ""
@@ -3355,18 +3059,10 @@ msgstr ""
msgid "Blocked text"
msgstr ""
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr ""
@@ -3537,6 +3233,10 @@ msgstr ""
msgid "Unmute notifications"
msgstr ""
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr ""
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr ""

View File

@@ -3,7 +3,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.53.6\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-03-20 18:13+0100\n"
"Last-Translator: Adrian Gonzalez <adrian@example.com>\n"
"Language: es\n"
@@ -608,15 +608,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr "Cambiar esto podría afectar el contenido de sus monitores existentes, posiblemente activar alertas, etc."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr "Elimine los elementos HTML mediante los selectores CSS y XPath antes de la conversión de texto."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr "No pegue HTML aquí, use solo selectores CSS y XPath"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr "Agregue múltiples elementos, selectores CSS o XPath por línea para ignorar múltiples partes del HTML."
@@ -861,26 +861,6 @@ msgstr "Etiqueta no encontrada"
msgid "Updated"
msgstr "Actualizado"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "Filtros y activadores"
@@ -895,6 +875,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -982,11 +966,7 @@ msgstr "Nombre de etiqueta/etiqueta"
msgid "No website organisational tags/groups configured"
msgstr "No hay etiquetas/grupos organizativos del sitio web configurados"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Silenciar notificaciones"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "Editar"
@@ -1195,14 +1175,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr "No se pudo cargar el procesador '{}'; es posible que falte el complemento del procesador."
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr "Monitor actualizado: ¡sin pausa!"
@@ -1215,10 +1187,6 @@ msgstr "Monitor actualizado."
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr "Vista previa no disponible: no se completó la búsqueda/verificación o no se alcanzaron los activadores"
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr "Esto eliminará el historial de versiones (instantáneas) de TODOS los monitores, ¡pero mantendrá su lista de URL!"
@@ -1328,17 +1296,14 @@ msgid "Jump"
msgstr "Saltar"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "Texto de error"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "Captura de pantalla de error"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "Texto"
@@ -1346,8 +1311,7 @@ msgstr "Texto"
msgid "Current screenshot"
msgstr "Captura de pantalla actual"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "Extraer datos"
@@ -1955,26 +1919,6 @@ msgstr "No hay monitores de detección de cambios de página web configuradas; a
msgid "import a list"
msgstr "importar una lista"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "Detección de reabastecimiento y precio"
@@ -2004,7 +1948,6 @@ msgid "Queued"
msgstr "En cola"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "Historia"
@@ -2038,168 +1981,6 @@ msgstr "Vuelva a comprobar todo"
msgid "in '%(title)s'"
msgstr "en '%(title)s'"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2484,26 +2265,10 @@ msgstr "Operación"
msgid "Selector"
msgstr "Selector"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "valor"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "Tiempo entre comprobaciones"
@@ -2592,8 +2357,7 @@ msgstr "Bloquear la detección de cambios mientras el texto coincide"
msgid "Execute JavaScript before change detection"
msgstr "Ejecute JavaScript antes de la detección de cambios"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "Guardar"
@@ -2661,7 +2425,7 @@ msgstr "Sintaxis de plantilla no válida:%(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr "Sintaxis de plantilla no válida en \"%(header)s\"encabezado:%(error)s"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "Nombre"
@@ -2757,10 +2521,6 @@ msgstr "Ignorar texto"
msgid "Ignore whitespace"
msgstr "Ignorar espacios en blanco"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "Debe estar entre 0 y 100"
@@ -2960,42 +2720,6 @@ msgstr "Reabastecimiento y detección de precios para páginas con un ÚNICO pro
msgid "Detects if the product goes back to in-stock"
msgstr "Detecta si el producto vuelve a estar en stock"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "Cambios en el texto/HTML, JSON y PDF de la página web"
@@ -3175,7 +2899,7 @@ msgstr "Lea la wiki de servicios de notificación aquí para obtener notas de co
msgid "Use"
msgstr "Usar"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "Mostrar ayuda y consejos avanzados"
@@ -3281,26 +3005,6 @@ msgstr "Para obtener una referencia completa de todos los filtros integrados de
msgid "Format for all notifications"
msgstr "Formato para todas las notificaciones"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr "Entrada"
@@ -3419,18 +3123,10 @@ msgstr "No se producirá ninguna detección de cambios porque este texto existe.
msgid "Blocked text"
msgstr "Texto bloqueado"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "Buscar o usar la tecla Alt+S"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "Actualizaciones en tiempo real sin conexión"
@@ -3610,6 +3306,10 @@ msgstr "La programación está en pausa: haga clic para reanudar"
msgid "Unmute notifications"
msgstr "Dejar de silenciar notificaciones"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Silenciar notificaciones"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "Las notificaciones están silenciadas: haga clic para activar el silencio"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n"
@@ -594,15 +594,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
@@ -833,26 +833,6 @@ msgstr ""
msgid "Updated"
msgstr "Muet"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "Filtres et déclencheurs"
@@ -867,6 +847,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -952,11 +936,7 @@ msgstr "Nom de l'étiquette/de l'étiquette"
msgid "No website organisational tags/groups configured"
msgstr "Aucun groupe/étiquette configuré"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Désactiver les notifications"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "Modifier"
@@ -1159,14 +1139,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr ""
@@ -1179,10 +1151,6 @@ msgstr "Supprimer les montres ?"
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr ""
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr ""
@@ -1292,17 +1260,14 @@ msgid "Jump"
msgstr "Saut"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "Texte d'erreur"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "Capture d'écran d'erreur"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "Texte"
@@ -1310,8 +1275,7 @@ msgstr "Texte"
msgid "Current screenshot"
msgstr "Capture d'écran actuelle"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "Extraire des données"
@@ -1903,26 +1867,6 @@ msgstr "Aucune surveillance de site Web configurée, veuillez ajouter une URL da
msgid "import a list"
msgstr "importer une liste"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "Détection du réapprovisionnement et du prix"
@@ -1952,7 +1896,6 @@ msgid "Queued"
msgstr "En file d'attente"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "Historique"
@@ -1986,168 +1929,6 @@ msgstr "Revérifiez tout"
msgid "in '%(title)s'"
msgstr "dans '%(title)s'"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2432,26 +2213,10 @@ msgstr "Options de l'interface utilisateur"
msgid "Selector"
msgstr "Mode de sélection :"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "Pause"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "Intervalle de vérification"
@@ -2540,8 +2305,7 @@ msgstr "Bloquer la détection des modifications lorsque le texte correspond"
msgid "Execute JavaScript before change detection"
msgstr "Exécuter JavaScript avant la détection des modifications"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "Sauvegarder"
@@ -2609,7 +2373,7 @@ msgstr "Syntaxe de modèle non valide : %(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "Nom"
@@ -2705,10 +2469,6 @@ msgstr "Ignorer le texte"
msgid "Ignore whitespace"
msgstr "Ignorer les espaces"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "Doit être compris entre 0 et 100"
@@ -2908,42 +2668,6 @@ msgstr "Détection de réapprovisionnement et de prix pour les pages avec un SEU
msgid "Detects if the product goes back to in-stock"
msgstr "Détecte si le produit revient en stock"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "Modifications du texte de la page Web/HTML, JSON et PDF"
@@ -3123,7 +2847,7 @@ msgstr ""
msgid "Use"
msgstr "Utiliser"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "Afficher l'aide et astuces avancées"
@@ -3227,26 +2951,6 @@ msgstr ""
msgid "Format for all notifications"
msgstr "Format pour toutes les notifications"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr ""
@@ -3363,18 +3067,10 @@ msgstr "Aucune détection de changement si ce texte existe."
msgid "Blocked text"
msgstr "Texte bloqué"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "Recherchez ou utilisez la touche Alt+S"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "Mises à jour en temps réel hors ligne"
@@ -3547,6 +3243,10 @@ msgstr ""
msgid "Unmute notifications"
msgstr "Réactiver les notifications"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Désactiver les notifications"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "Notifications désactivées - cliquez pour réactiver"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-02 15:32+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: it\n"
@@ -592,15 +592,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
@@ -831,26 +831,6 @@ msgstr ""
msgid "Updated"
msgstr "Aggiornato"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr ""
@@ -865,6 +845,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -950,11 +934,7 @@ msgstr ""
msgid "No website organisational tags/groups configured"
msgstr "Nessun gruppo/etichetta configurato"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Disattiva notifiche"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "Modifica"
@@ -1157,14 +1137,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr ""
@@ -1177,10 +1149,6 @@ msgstr ""
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr ""
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr ""
@@ -1290,17 +1258,14 @@ msgid "Jump"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "Testo dell'errore"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "Screenshot dell'errore"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "Testo"
@@ -1308,8 +1273,7 @@ msgstr "Testo"
msgid "Current screenshot"
msgstr "Screenshot corrente"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr ""
@@ -1899,26 +1863,6 @@ msgstr "Nessun monitoraggio configurato, aggiungi un URL nella casella sopra, op
msgid "import a list"
msgstr "importa una lista"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr ""
@@ -1948,7 +1892,6 @@ msgid "Queued"
msgstr "In coda"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "Cronologia"
@@ -1982,168 +1925,6 @@ msgstr "Controlla tutti"
msgid "in '%(title)s'"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2426,26 +2207,10 @@ msgstr "Operazione"
msgid "Selector"
msgstr "Selettore"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "valore"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "Intervallo tra controlli"
@@ -2534,8 +2299,7 @@ msgstr "Blocca rilevamento modifiche quando il testo corrisponde"
msgid "Execute JavaScript before change detection"
msgstr "Esegui JavaScript prima del rilevamento"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "Salva"
@@ -2603,7 +2367,7 @@ msgstr "Sintassi template non valida: %(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "Nome"
@@ -2699,10 +2463,6 @@ msgstr "Ignora testo"
msgid "Ignore whitespace"
msgstr "Ignora spazi"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "Deve essere tra 0 e 100"
@@ -2902,42 +2662,6 @@ msgstr "Rilevamento disponibilità e prezzi per pagine con UN SINGOLO prodotto"
msgid "Detects if the product goes back to in-stock"
msgstr "Rileva se il prodotto torna disponibile"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "Modifiche testo/HTML, JSON e PDF"
@@ -3117,7 +2841,7 @@ msgstr ""
msgid "Use"
msgstr "Usa"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr ""
@@ -3221,26 +2945,6 @@ msgstr ""
msgid "Format for all notifications"
msgstr "Formato per tutte le notifiche"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr ""
@@ -3357,18 +3061,10 @@ msgstr "Nessuna rilevazione se questo testo esiste."
msgid "Blocked text"
msgstr "Testo bloccato"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "Cerca, o usa il tasto Alt+S"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr ""
@@ -3539,6 +3235,10 @@ msgstr ""
msgid "Unmute notifications"
msgstr "Riattiva notifiche"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Disattiva notifiche"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.53.6\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-03-31 23:52+0900\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: ja\n"
@@ -600,15 +600,15 @@ msgstr "アンカータグのコンテンツをレンダリング(デフォル
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr "これを変更すると、既存のウォッチのコンテンツに影響し、アラートが発生する可能性があります。"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr "テキスト変換前に CSS および XPath セレクターで HTML 要素を削除します。"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr "ここにHTMLを貼り付けないでください。CSSとXPathセレクターのみを使用してください。"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr "1行に1つのCSSまたはXPathセレクターを追加して、HTMLの複数の部分を無視できます。"
@@ -839,26 +839,6 @@ msgstr "タグが見つかりません"
msgid "Updated"
msgstr "更新しました"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr "このタグ/グループ内の個別ウォッチに対して有効にしますか?"
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr "URLが一致するウォッチに自動適用"
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr "例: *://example.com/* や github.com/myorg"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr "タグの色"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr "タグ名"
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "フィルタとトリガー"
@@ -873,6 +853,10 @@ msgstr "URLが一致するウォッチにこのタグを自動適用します。
msgid "Currently matching watches"
msgstr "現在マッチしているウォッチ"
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr "タグの色"
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr "カスタム色"
@@ -917,7 +901,7 @@ msgid "Lookout!"
msgstr "注意!"
# 訳注: "There are" + <a>"system-wide notification URLs enabled"</a> + ", " + ...
# → 「システム全体の通知URLが有効化されています、...」
# → 「 件のシステム全体の通知URLが有効化されています、...」
# 前半は日本語では不要なため、空白1文字で非表示にする空文字は英語にフォールバックするため
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "There are"
@@ -925,7 +909,7 @@ msgstr " "
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "system-wide notification URLs enabled"
msgstr "システム全体の通知URLが有効化されています"
msgstr "件のシステム全体の通知URLが有効化されています"
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "this form will override notification settings for this watch only"
@@ -964,11 +948,7 @@ msgstr "タグ / ラベル名"
msgid "No website organisational tags/groups configured"
msgstr "ウェブサイトの組織タグ/グループが設定されていません"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "通知をミュート"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "編集"
@@ -1171,14 +1151,6 @@ msgstr "「{}」プロセッサーを読み込めませんでした。プロセ
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr "「{}」プロセッサーを読み込めませんでした。プロセッサープラグインが見つからない可能性があります。"
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr "システム設定のデフォルト"
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr "デフォルト"
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr "ウォッチを更新しました - 一時停止を解除しました!"
@@ -1191,10 +1163,6 @@ msgstr "ウォッチを更新しました。"
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr "プレビューを表示できません - 取得/チェックが完了していないか、トリガーに達していません"
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr "差分"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr "これにより、すべてのウォッチのバージョン履歴スナップショットが削除されますが、URLのリストは保持されます"
@@ -1307,17 +1275,14 @@ msgid "Jump"
msgstr "ジャンプ"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "エラーテキスト"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "エラースクリーンショット"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "テキスト"
@@ -1325,8 +1290,7 @@ msgstr "テキスト"
msgid "Current screenshot"
msgstr "現在のスクリーンショット"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "データを抽出"
@@ -1926,26 +1890,6 @@ msgstr "ウェブページ変更検知ウォッチが設定されていません
msgid "import a list"
msgstr "リストをインポート"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr "チェックを一時停止"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr "チェックを再開"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr "通知をミュート"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr "通知のミュートを解除"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr "ウォッチ設定を他の人と共有するリンクを作成"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "在庫補充と価格を検知中"
@@ -1975,7 +1919,6 @@ msgid "Queued"
msgstr "キュー済み"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "履歴"
@@ -2009,168 +1952,6 @@ msgstr "すべて再チェック"
msgid "in '%(title)s'"
msgstr "'%(title)s' 内"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr "選択してください - 演算子"
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr "より大きい"
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr "より小さい"
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr "以上"
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr "以下"
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr "等しい"
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr "等しくない"
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr "含む"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr "選択してください - フィールド"
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr "含まない"
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr "テキストが次で始まる"
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr "テキストが次で終わる"
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr "文字数の最小値"
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr "文字数の最大値"
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr "テキストが正規表現にマッチ"
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr "テキストが正規表現にマッチしない"
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr "'フィルタとトリガー' 後に抽出された数値"
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr "'フィルタとトリガー' 後のページテキスト"
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr "フィールド"
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr "演算子"
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr "値"
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr "演算子は必須です。"
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr "フィールドは必須です。"
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr "値は必須です。"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr "レーベンシュタイン - テキスト類似度"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr "レーベンシュタイン - テキスト変化距離"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr "レーベンシュタイン指標を計算するのに十分な履歴がありません"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr "スナップショットが大きすぎるため、編集統計をスキップします。"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr "レーベンシュタイン指標を計算できません"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr "レーベンシュタイン テキスト類似度の詳細"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr "編集距離(必要な編集回数)"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr "類似度"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr "類似度(%"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr "レーベンシュタイン指標は直近2つのスナップショットを比較し、一方を他方に変換するために必要な文字編集回数を測定します。"
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr "レーベンシュタイン指標の計算中にエラーが発生しました"
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr "コンテンツの単語数"
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr "コンテンツ分析"
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr "単語数(最新のスナップショット)"
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr "単語数はコンテンツの長さを示す簡易指標で、テキストを空白で分割して計算されます。"
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr "シンプルで高速なプレーンテキスト/HTTPクライアント"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2453,26 +2234,10 @@ msgstr "操作"
msgid "Selector"
msgstr "セレクター"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr "CSS または xPath セレクター"
#: changedetectionio/forms.py
msgid "value"
msgstr "値"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr "値"
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr "ウェブページ URL"
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr "グループタグ"
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "チェック間隔"
@@ -2561,8 +2326,7 @@ msgstr "テキストが一致している間は変更検知をブロック"
msgid "Execute JavaScript before change detection"
msgstr "変更検知前にJavaScriptを実行"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "保存"
@@ -2630,7 +2394,7 @@ msgstr "無効なテンプレート構文: %(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr "\"%(header)s\" ヘッダーのテンプレート構文が無効です: %(error)s"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "名前"
@@ -2726,10 +2490,6 @@ msgstr "無視するテキスト"
msgid "Ignore whitespace"
msgstr "空白を無視"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr "スクリーンショット: 最小変化率"
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "0から100の間で指定してください"
@@ -2929,42 +2689,6 @@ msgstr "単一製品ページの在庫補充&価格検知"
msgid "Detects if the product goes back to in-stock"
msgstr "製品が在庫ありに戻ったかどうかを検知します"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr "スクリーンショット"
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr "このツールはすべてのウォッチ履歴からテキストデータを抽出します。"
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr "<strong>正規表現</strong> は、抽出したいテキスト内の部分を正確に特定するためのパターンです。"
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr "例えば、テキストから数値のみを抽出するには"
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr "元のテキスト"
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr "抽出用の正規表現:"
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr "ここで正規表現をテストしてください。"
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr "正規表現の各グループ括弧"
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr "は独立したカラムとなり、最初のカラムの値は常に日付です。"
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "ウェブページのテキスト/HTML、JSONおよびPDFの変更"
@@ -3156,7 +2880,7 @@ msgstr "重要な設定に関するメモについては、通知サービスの
msgid "Use"
msgstr " "
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "詳細なヘルプとヒントを表示"
@@ -3267,30 +2991,6 @@ msgstr "すべての Jinja2 組み込みフィルタの完全なリファレン
msgid "Format for all notifications"
msgstr "すべての通知のフォーマット"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr "注意"
# 訳注: "Discord does not render HTML — switch to <Plain Text> format to avoid <&nbsp;> and other HTML entities appearing
# literally in your notifications."
# → 「Discord は HTML をレンダリングしません。プレーンテキスト形式に切り替えて、通知で &nbsp; などの HTML エンティティがそのまま表示されるのを回避してください。」
# 3分割された文をプレースホルダー位置に合わせて再配分
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr "Discord は HTML をレンダリングしません。"
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr "プレーンテキスト"
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr " 形式に切り替えて、通知で "
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr " などの HTML エンティティがそのまま表示されるのを回避してください。"
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr "エントリー"
@@ -3410,18 +3110,10 @@ msgstr "このテキストが存在するため、変更検知は実行されま
msgid "Blocked text"
msgstr "ブロックされたテキスト"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr "新しいバージョンが利用可能です"
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "検索、またはAlt+Sキーを使用"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr "このリンクを共有:"
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "リアルタイム更新オフライン"
@@ -3597,6 +3289,10 @@ msgstr "スケジューリングは一時停止中 - クリックして再開"
msgid "Unmute notifications"
msgstr "通知のミュートを解除"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "通知をミュート"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "通知はミュート中 - クリックして解除"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: ko\n"
@@ -590,15 +590,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
@@ -829,26 +829,6 @@ msgstr ""
msgid "Updated"
msgstr "업데이트됨"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "필터 및 트리거"
@@ -863,6 +843,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -948,11 +932,7 @@ msgstr "태그/라벨 이름"
msgid "No website organisational tags/groups configured"
msgstr "구성된 그룹/태그 없음"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "알림 음소거"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "편집하다"
@@ -1155,14 +1135,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr ""
@@ -1175,10 +1147,6 @@ msgstr "모니터가 업데이트되었습니다."
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr ""
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr ""
@@ -1288,17 +1256,14 @@ msgid "Jump"
msgstr "도약"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "오류 텍스트"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "오류 스크린샷"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "텍스트"
@@ -1306,8 +1271,7 @@ msgstr "텍스트"
msgid "Current screenshot"
msgstr "현재 스크린샷"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "데이터 추출"
@@ -1897,26 +1861,6 @@ msgstr "구성된 웹사이트 시계가 없습니다. 위 상자에 URL을 추
msgid "import a list"
msgstr "목록 가져오기"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "재입고 및 가격 감지"
@@ -1946,7 +1890,6 @@ msgid "Queued"
msgstr "대기 중"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "기록"
@@ -1980,168 +1923,6 @@ msgstr "모두 다시 확인하세요"
msgid "in '%(title)s'"
msgstr "'%(title)s'에서"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2424,26 +2205,10 @@ msgstr "작업"
msgid "Selector"
msgstr "선택자"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "값"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "확인 간격"
@@ -2532,8 +2297,7 @@ msgstr "텍스트가 일치하는 동안 변경 감지 차단"
msgid "Execute JavaScript before change detection"
msgstr "변경 감지 전에 JavaScript 실행"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "구하다"
@@ -2601,7 +2365,7 @@ msgstr "잘못된 템플릿 구문: %(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "이름"
@@ -2697,10 +2461,6 @@ msgstr "텍스트 무시"
msgid "Ignore whitespace"
msgstr "공백 무시"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "0에서 100 사이여야 합니다."
@@ -2900,42 +2660,6 @@ msgstr "단일 제품이 포함된 페이지의 재입고 및 가격 감지"
msgid "Detects if the product goes back to in-stock"
msgstr "제품이 다시 재고로 돌아왔는지 감지합니다."
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "웹페이지 텍스트/HTML, JSON 및 PDF 변경"
@@ -3115,7 +2839,7 @@ msgstr ""
msgid "Use"
msgstr "사용"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "고급 도움말 표시"
@@ -3219,26 +2943,6 @@ msgstr ""
msgid "Format for all notifications"
msgstr "모든 알림 형식"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr ""
@@ -3355,18 +3059,10 @@ msgstr "이 텍스트 존재 시 변경 감지 안 함."
msgid "Blocked text"
msgstr ""
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "검색 또는 Alt+S 키 사용"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "실시간 업데이트 오프라인"
@@ -3537,6 +3233,10 @@ msgstr ""
msgid "Unmute notifications"
msgstr "알림 음소거 해제"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "알림 음소거"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "알림 음소거됨 - 클릭하여 해제"

View File

@@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.54.9\n"
"Project-Id-Version: changedetection.io 0.54.8\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -589,15 +589,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
@@ -828,26 +828,6 @@ msgstr ""
msgid "Updated"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr ""
@@ -862,6 +842,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -947,11 +931,7 @@ msgstr ""
msgid "No website organisational tags/groups configured"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr ""
@@ -1154,14 +1134,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr ""
@@ -1174,10 +1146,6 @@ msgstr ""
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr ""
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr ""
@@ -1287,17 +1255,14 @@ msgid "Jump"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr ""
@@ -1305,8 +1270,7 @@ msgstr ""
msgid "Current screenshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr ""
@@ -1896,26 +1860,6 @@ msgstr ""
msgid "import a list"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr ""
@@ -1945,7 +1889,6 @@ msgid "Queued"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr ""
@@ -1979,168 +1922,6 @@ msgstr ""
msgid "in '%(title)s'"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2423,26 +2204,10 @@ msgstr ""
msgid "Selector"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr ""
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr ""
@@ -2531,8 +2296,7 @@ msgstr ""
msgid "Execute JavaScript before change detection"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr ""
@@ -2600,7 +2364,7 @@ msgstr ""
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr ""
@@ -2696,10 +2460,6 @@ msgstr ""
msgid "Ignore whitespace"
msgstr ""
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr ""
@@ -2899,42 +2659,6 @@ msgstr ""
msgid "Detects if the product goes back to in-stock"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr ""
@@ -3114,7 +2838,7 @@ msgstr ""
msgid "Use"
msgstr ""
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr ""
@@ -3218,26 +2942,6 @@ msgstr ""
msgid "Format for all notifications"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr ""
@@ -3354,18 +3058,10 @@ msgstr ""
msgid "Blocked text"
msgstr ""
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr ""
@@ -3536,6 +3232,10 @@ msgstr ""
msgid "Unmute notifications"
msgstr ""
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr ""
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.54.8\n"
"Report-Msgid-Bugs-To: mstrey@gmail.com\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-04-07 22:00-0300\n"
"Last-Translator: Gemini AI\n"
"Language: pt_BR\n"
@@ -599,15 +599,15 @@ msgstr "Renderizar conteúdo da tag âncora, desativado por padrão. Se ativado,
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr "Alterar isso pode afetar o conteúdo dos seus monitoramentos existentes, possivelmente disparando alertas, etc."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr "Remover elementos HTML por seletores CSS e XPath antes da conversão de texto."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr "Não cole HTML aqui, use apenas seletores CSS e XPath"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr "Adicione múltiplos elementos, seletores CSS ou XPath por linha para ignorar várias partes do HTML."
@@ -848,26 +848,6 @@ msgstr "Tag não encontrada"
msgid "Updated"
msgstr "Atualizado"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "Filtros e Gatilhos"
@@ -882,6 +862,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -969,11 +953,7 @@ msgstr "Nome da Tag / Rótulo"
msgid "No website organisational tags/groups configured"
msgstr "Nenhum grupo ou tag organizacional configurado"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Silenciar notificações"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "Editar"
@@ -1180,14 +1160,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr "Não foi possível carregar o processador '{}', o plugin pode estar faltando."
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr "Monitoramento atualizado - retomado!"
@@ -1200,10 +1172,6 @@ msgstr "Monitoramento atualizado."
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr "Pré-visualização indisponível - Nenhuma busca concluída ou gatilhos não atingidos"
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr "Isso removerá o histórico de versões (instantâneos) para TODOS os monitoramentos, mas manterá sua lista de URLs!"
@@ -1313,17 +1281,14 @@ msgid "Jump"
msgstr "Pular"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "Texto de Erro"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "Screenshot de Erro"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "Texto"
@@ -1331,8 +1296,7 @@ msgstr "Texto"
msgid "Current screenshot"
msgstr "Screenshot atual"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "Extrair Dados"
@@ -1930,26 +1894,6 @@ msgstr "Nenhum monitoramento configurado, adicione uma URL na caixa acima ou"
msgid "import a list"
msgstr "importe uma lista"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "Detectando estoque e preço"
@@ -1979,7 +1923,6 @@ msgid "Queued"
msgstr "Enfileirado"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "Histórico"
@@ -2013,168 +1956,6 @@ msgstr "Rechecar todos"
msgid "in '%(title)s'"
msgstr "em '%(title)s'"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2457,26 +2238,10 @@ msgstr "Operação"
msgid "Selector"
msgstr "Seletor"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "valor"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "Tempo Entre Verificações"
@@ -2565,8 +2330,7 @@ msgstr "Bloquear detecção de mudança enquanto o texto corresponder"
msgid "Execute JavaScript before change detection"
msgstr "Executar JavaScript antes da detecção de mudanças"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "Salvar"
@@ -2634,7 +2398,7 @@ msgstr "Sintaxe de modelo inválida: %(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr "Sintaxe de modelo inválida no cabeçalho \"%(header)s\": %(error)s"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "Nome"
@@ -2730,10 +2494,6 @@ msgstr "Ignorar Texto"
msgid "Ignore whitespace"
msgstr "Ignorar espaços"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "Deve estar entre 0 e 100"
@@ -2933,42 +2693,6 @@ msgstr "Detecção de Estoque e Preço para páginas com um ÚNICO produto"
msgid "Detects if the product goes back to in-stock"
msgstr "Detecta se o produto volta ao estoque"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "Mudanças em Texto/HTML de páginas, JSON e PDF"
@@ -3148,7 +2872,7 @@ msgstr "Por favor, leia a wiki dos serviços de notificação aqui para notas im
msgid "Use"
msgstr "Use"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "Mostrar ajuda avançada e dicas"
@@ -3252,26 +2976,6 @@ msgstr "Para uma referência completa de todos os filtros nativos do Jinja2, os
msgid "Format for all notifications"
msgstr "Formato para todas as notificações"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr "Entrada"
@@ -3390,18 +3094,10 @@ msgstr "Nenhuma detecção de mudança ocorrerá porque este texto existe."
msgid "Blocked text"
msgstr "Texto bloqueado"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "Pesquisar, ou use a tecla Alt+S"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "Atualizações em tempo real offline"
@@ -3578,6 +3274,10 @@ msgstr "O agendamento está pausado - clique para retomar"
msgid "Unmute notifications"
msgstr "Reativar notificações"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Silenciar notificações"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "As notificações estão silenciadas - clique para reativar"

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.53.6\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-04-10 20:38+0300\n"
"Last-Translator: \n"
"Language: tr\n"
@@ -607,15 +607,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr "Bunu değiştirmek mevcut izleyicilerinizin içeriğini etkileyebilir, muhtemelen uyarıları vb. tetikleyebilir."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr "Metin dönüştürmeden önce HTML öğelerini CSS ve XPath seçicilere göre kaldırın."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr "Buraya HTML yapıştırmayın, yalnızca CSS ve XPath seçicilerini kullanın"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr "HTML'nin birden çok bölümünü yoksaymak için satır başına birden çok öğe, CSS veya XPath seçici ekleyin."
@@ -856,26 +856,6 @@ msgstr "Etiket bulunamadı"
msgid "Updated"
msgstr "Güncellendi"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "Filtreler ve Tetikleyiciler"
@@ -890,6 +870,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -975,11 +959,7 @@ msgstr "Etiket / İsim adı"
msgid "No website organisational tags/groups configured"
msgstr "Yapılandırılmış web sitesi organizasyonel etiketi/grubu yok"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Bildirimleri sessize al"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "Düzenle"
@@ -1184,14 +1164,6 @@ msgstr "'{}' işlemcisi yüklenemedi, işlemci eklentisi eksik olabilir. Lütfen
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr "'{}' işlemcisi yüklenemedi, işlemci eklentisi eksik olabilir."
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr "İzleyici güncellendi - duraklatma iptal edildi!"
@@ -1204,10 +1176,6 @@ msgstr "İzleyici güncellendi."
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr "Önizleme kullanılamıyor - Getirme/kontrol tamamlanmadı veya tetikleyicilere ulaşılamadı"
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr "Bu işlem TÜM izleyiciler için sürüm geçmişini (anlık görüntüleri) kaldıracak, ancak URL listenizi koruyacaktır!"
@@ -1317,17 +1285,14 @@ msgid "Jump"
msgstr "Atla"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "Hata Metni"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "Hata Ekran Görüntüsü"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "Metin"
@@ -1335,8 +1300,7 @@ msgstr "Metin"
msgid "Current screenshot"
msgstr "Mevcut ekran görüntüsü"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "Veriyi Çıkar"
@@ -1940,26 +1904,6 @@ msgstr "Yapılandırılmış web sayfası değişiklik tespiti izleyicisi yok, l
msgid "import a list"
msgstr "bir listeyi içe aktarın"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "Yeniden stoklama ve fiyat tespiti"
@@ -1989,7 +1933,6 @@ msgid "Queued"
msgstr "Sırada"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "Geçmiş"
@@ -2023,168 +1966,6 @@ msgstr "Tümünü yeniden kontrol et"
msgid "in '%(title)s'"
msgstr "'%(title)s' içinde"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2467,26 +2248,10 @@ msgstr "Operasyon"
msgid "Selector"
msgstr "Seçici"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "değer"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "Kontrol Arasındaki Süre"
@@ -2575,8 +2340,7 @@ msgstr "Metin eşleşirken değişiklik tespitini engelle"
msgid "Execute JavaScript before change detection"
msgstr "Değişiklik tespitinden önce JavaScript'i çalıştır"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "Kaydet"
@@ -2644,7 +2408,7 @@ msgstr "Geçersiz şablon sözdizimi: %(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr "\"%(header)s\" başlığında geçersiz şablon sözdizimi: %(error)s"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "İsim"
@@ -2740,10 +2504,6 @@ msgstr "Metni Yoksay"
msgid "Ignore whitespace"
msgstr "Boşlukları yoksay"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "0 ile 100 arasında olmalıdır"
@@ -2943,42 +2703,6 @@ msgstr "TEK bir ürüne sahip sayfalar için Yeniden Stoklama ve Fiyat tespiti"
msgid "Detects if the product goes back to in-stock"
msgstr "Ürünün tekrar stoka girip girmediğini tespit eder"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "Web Sayfası Metin/HTML, JSON ve PDF değişiklikleri"
@@ -3158,7 +2882,7 @@ msgstr "Önemli yapılandırma notları için lütfen buradaki bildirim hizmetle
msgid "Use"
msgstr "Şu"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "Gelişmiş yardım ve ipuçlarını göster"
@@ -3262,26 +2986,6 @@ msgstr "Tüm Jinja2 yerleşik filtrelerinin tam bir referansı için kullanıcı
msgid "Format for all notifications"
msgstr "Tüm bildirimler için format"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr "Giriş"
@@ -3400,18 +3104,10 @@ msgstr "Bu metin var olduğu için değişiklik tespiti yapılmayacaktır."
msgid "Blocked text"
msgstr "Engellenen metin"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "Arayın veya Alt+S Tuşunu Kullanın"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "Gerçek zamanlı güncellemeler çevrimdışı"
@@ -3590,6 +3286,10 @@ msgstr "Zamanlama duraklatıldı - devam etmek için tıklayın"
msgid "Unmute notifications"
msgstr "Bildirimlerin sesini aç"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Bildirimleri sessize al"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "Bildirimler sessize alındı - sesini açmak için tıklayın"

View File

@@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io\n"
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-02-19 12:30+0100\n"
"Last-Translator: \n"
"Language: uk\n"
@@ -589,15 +589,15 @@ msgstr "Відображати вміст тегів посилань. За за
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr "Зміна цього параметра може вплинути на вміст ваших завдань та викликати хибні спрацювання."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr "Видалити HTML-елемент(и) за допомогою CSS та XPath селекторів перед перетворенням у текст."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr "Не вставляйте сюди HTML, використовуйте лише CSS та XPath селектори"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr "Додайте кілька елементів, CSS або XPath селекторів (по одному на рядок), щоб ігнорувати частини HTML."
@@ -836,26 +836,6 @@ msgstr "Тег не знайдено"
msgid "Updated"
msgstr "Оновлено"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "Фільтри та Тригери"
@@ -870,6 +850,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -955,11 +939,7 @@ msgstr "Ім'я Тегу / Мітки"
msgid "No website organisational tags/groups configured"
msgstr "Організаційні теги/групи сайтів не налаштовані"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Вимкнути сповіщення"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "Редагувати"
@@ -1164,14 +1144,6 @@ msgstr "Не вдалося завантажити процесор '{}', мож
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr "Не вдалося завантажити процесор '{}', можливо плагін відсутній."
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr "Завдання оновлено — знято з паузи!"
@@ -1184,10 +1156,6 @@ msgstr "Завдання оновлено."
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr "Попередній перегляд недоступний — перевірка не завершена або тригери не спрацювали"
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr "Це видалить історію версій (знімки) для ВСІХ завдань, але збереже ваш список URL!"
@@ -1297,17 +1265,14 @@ msgid "Jump"
msgstr "Перейти"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "Текст помилки"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "Скріншот помилки"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "Текст"
@@ -1315,8 +1280,7 @@ msgstr "Текст"
msgid "Current screenshot"
msgstr "Поточний скріншот"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "Вилучити дані"
@@ -1914,26 +1878,6 @@ msgstr "Немає налаштованих завдань для відстеж
msgid "import a list"
msgstr "імпортуйте список"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "Визначення наявності та ціни"
@@ -1963,7 +1907,6 @@ msgid "Queued"
msgstr "В черзі"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "Історія"
@@ -1997,168 +1940,6 @@ msgstr "Перевірити всі"
msgid "in '%(title)s'"
msgstr "в '%(title)s'"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2441,26 +2222,10 @@ msgstr "Операція"
msgid "Selector"
msgstr "Селектор"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "значення"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "Час між перевірками"
@@ -2549,8 +2314,7 @@ msgstr "Блокувати виявлення змін, поки текст зб
msgid "Execute JavaScript before change detection"
msgstr "Виконати JavaScript перед виявленням змін"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "Зберегти"
@@ -2618,7 +2382,7 @@ msgstr "Невірний синтаксис шаблону: %(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr "Невірний синтаксис шаблону в заголовку \"%(header)s\": %(error)s"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "Ім'я"
@@ -2714,10 +2478,6 @@ msgstr "Ігнорувати текст"
msgid "Ignore whitespace"
msgstr "Ігнорувати пробіли"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "Має бути між 0 та 100"
@@ -2917,42 +2677,6 @@ msgstr "Виявлення поповнення та ціни для сторі
msgid "Detects if the product goes back to in-stock"
msgstr "Визначає, чи повернувся товар у наявність"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "Зміни тексту веб-сторінки/HTML, JSON та PDF"
@@ -3132,7 +2856,7 @@ msgstr "Будь ласка, прочитайте вікі по сервісах
msgid "Use"
msgstr "Використовуйте"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "Показати розширену довідку та поради"
@@ -3236,26 +2960,6 @@ msgstr "Для повного довідника по всіх вбудован
msgid "Format for all notifications"
msgstr "Формат для всіх сповіщень"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr "Запис"
@@ -3372,18 +3076,10 @@ msgstr "Виявлення змін не відбудеться, оскільк
msgid "Blocked text"
msgstr "Блокуючий текст"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "Пошук, або використовуйте Alt+S"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "Оновлення в реальному часі вимкнено"
@@ -3558,6 +3254,10 @@ msgstr "Планування на паузі - натисніть для від
msgid "Unmute notifications"
msgstr "Увімкнути сповіщення"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "Вимкнути сповіщення"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "Сповіщення вимкнено - натисніть для увімкнення"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-18 21:31+0800\n"
"Last-Translator: 吾爱分享 <admin@wuaishare.cn>\n"
"Language: zh\n"
@@ -590,15 +590,15 @@ msgstr "渲染 a 标签内容,默认关闭,开启后链接会呈现为"
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr "更改此项可能影响现有监控项内容,可能触发警报等。"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr "在文本转换前通过 CSS 和 XPath 选择器移除 HTML 元素。"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr "不要在此粘贴 HTML仅使用 CSS 和 XPath 选择器"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr "每行添加多个元素、CSS 或 XPath 选择器,用于忽略 HTML 的多个部分。"
@@ -829,26 +829,6 @@ msgstr "未找到标签"
msgid "Updated"
msgstr "已更新"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "过滤器与触发器"
@@ -863,6 +843,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -948,11 +932,7 @@ msgstr "标签/名称"
msgid "No website organisational tags/groups configured"
msgstr "未配置分组/标签"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "静音通知"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "编辑"
@@ -1155,14 +1135,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr "监控项已更新并取消暂停!"
@@ -1175,10 +1147,6 @@ msgstr "监控项已更新。"
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr "无法预览 - 尚未完成抓取/检查或未满足触发条件"
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr "这将删除所有监控项的版本历史(快照),但会保留 URL 列表!"
@@ -1288,17 +1256,14 @@ msgid "Jump"
msgstr "跳转"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "错误文本"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "错误截图"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "文本"
@@ -1306,8 +1271,7 @@ msgstr "文本"
msgid "Current screenshot"
msgstr "当前截图"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "提取数据"
@@ -1897,26 +1861,6 @@ msgstr "尚未配置网站监控项,请在上方输入 URL 或"
msgid "import a list"
msgstr "导入列表"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "检测补货与价格"
@@ -1946,7 +1890,6 @@ msgid "Queued"
msgstr "队列中"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "历史"
@@ -1980,168 +1923,6 @@ msgstr "重新检查全部"
msgid "in '%(title)s'"
msgstr "(“%(title)s”中"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2424,26 +2205,10 @@ msgstr "操作"
msgid "Selector"
msgstr "选择器"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "值"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "检查间隔"
@@ -2532,8 +2297,7 @@ msgstr "文本匹配时阻止变更检测"
msgid "Execute JavaScript before change detection"
msgstr "在变更检测前执行 JavaScript"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "保存"
@@ -2601,7 +2365,7 @@ msgstr "模板语法无效:%(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr "“%(header)s”请求头中的模板语法无效%(error)s"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "名称"
@@ -2697,10 +2461,6 @@ msgstr "忽略文本"
msgid "Ignore whitespace"
msgstr "忽略空白"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "必须介于 0 到 100 之间"
@@ -2900,42 +2660,6 @@ msgstr "适用于单一商品页面的补货与价格检测"
msgid "Detects if the product goes back to in-stock"
msgstr "检测商品是否恢复有库存"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "网页文本/HTML、JSON 和 PDF 变更"
@@ -3115,7 +2839,7 @@ msgstr "请阅读通知服务 Wiki 以了解重要配置说明"
msgid "Use"
msgstr "使用"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "显示高级帮助和提示"
@@ -3219,26 +2943,6 @@ msgstr "关于 Jinja2 内置过滤器的完整参考,请见"
msgid "Format for all notifications"
msgstr "所有通知的格式"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr "条目"
@@ -3355,18 +3059,10 @@ msgstr "此文本存在时将不会进行变更检测。"
msgid "Blocked text"
msgstr "阻止文本"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "搜索,或使用 Alt+S 快捷键"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "实时更新离线"
@@ -3537,6 +3233,10 @@ msgstr "调度已暂停 - 点击恢复"
msgid "Unmute notifications"
msgstr "取消静音通知"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "静音通知"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "通知已静音 - 点击取消静音"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-04-15 03:04+0900\n"
"POT-Creation-Date: 2026-04-11 04:15+0200\n"
"PO-Revision-Date: 2026-01-15 12:00+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh_Hant_TW\n"
@@ -590,15 +590,15 @@ msgstr ""
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
@@ -829,26 +829,6 @@ msgstr "找不到標籤"
msgid "Updated"
msgstr "已更新"
#: changedetectionio/blueprint/tags/form.py
msgid "Activate for individual watches in this tag/group?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Auto-apply to watches with URLs matching"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "e.g. *://example.com/* or github.com/myorg"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Filters & Triggers"
msgstr "過濾器與觸發器"
@@ -863,6 +843,10 @@ msgstr ""
msgid "Currently matching watches"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/templates/edit-tag.html
msgid "Custom colour"
msgstr ""
@@ -948,11 +932,7 @@ msgstr "標籤 / 名稱"
msgid "No website organisational tags/groups configured"
msgstr "未設定群組/標籤"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "靜音通知"
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/edit.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Edit"
msgstr "編輯"
@@ -1155,14 +1135,6 @@ msgstr ""
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "System settings default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Default"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
msgstr "已更新監測任務 - 已取消暫停!"
@@ -1175,10 +1147,6 @@ msgstr "已更新監測任務。"
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
msgstr "預覽無法使用 - 未完成抓取 / 檢查或未觸發"
#: changedetectionio/blueprint/ui/preview.py
msgid "Diff"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgstr "這將移除「所有」監測任務的版本歷史記錄(快照),但保留您的 URL 列表!"
@@ -1288,17 +1256,14 @@ msgid "Jump"
msgstr "跳轉"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Text"
msgstr "錯誤文字"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Error Screenshot"
msgstr "錯誤截圖"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
#: changedetectionio/processors/templates/extract.html
msgid "Text"
msgstr "文字"
@@ -1306,8 +1271,7 @@ msgstr "文字"
msgid "Current screenshot"
msgstr "目前截圖"
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/processors/extract.py
#: changedetectionio/processors/templates/extract.html
#: changedetectionio/blueprint/ui/templates/diff.html
msgid "Extract Data"
msgstr "提取資料"
@@ -1897,26 +1861,6 @@ msgstr "未設定網站監測任務,請在上方欄位新增 URL或"
msgid "import a list"
msgstr "匯入列表"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnPause checks"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Mute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "UnMute notification"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a link to share watch config with others"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Detecting restock and price"
msgstr "檢測補貨與價格"
@@ -1946,7 +1890,6 @@ msgid "Queued"
msgstr "已排程"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
#: changedetectionio/processors/text_json_diff/difference.py
msgid "History"
msgstr "歷史記錄"
@@ -1980,168 +1923,6 @@ msgstr "複查全部"
msgid "in '%(title)s'"
msgstr "於 '%(title)s'"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Operator"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Does NOT Contain"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Starts With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Ends With"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length minimum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Length maximum"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Matches Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Text Does NOT Match Regex"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Extracted number after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/default_plugin.py
msgid "Page text after 'Filters & Triggers'"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "A value"
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Operator is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Field is required."
msgstr ""
#: changedetectionio/conditions/form.py
msgid "Value is required."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein - Text change distance"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Not enough history to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Snapshot too large for edit statistics, skipping."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Unable to calculate Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Levenshtein Text Similarity Details"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Raw distance (edits needed)"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Similarity ratio"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Percent similar"
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid ""
"Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one "
"into the other."
msgstr ""
#: changedetectionio/conditions/plugins/levenshtein_plugin.py
msgid "Error calculating Levenshtein metrics"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count of content"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Content Analysis"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count (latest snapshot)"
msgstr ""
#: changedetectionio/conditions/plugins/wordcount_plugin.py
msgid "Word count is a simple measure of content length, calculated by splitting text on whitespace."
msgstr ""
#: changedetectionio/content_fetchers/requests.py
msgid "Basic fast Plaintext/HTTP Client"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py
#: changedetectionio/realtime/socket_server.py
msgid "Not yet"
@@ -2424,26 +2205,10 @@ msgstr "操作"
msgid "Selector"
msgstr "選擇器"
#: changedetectionio/forms.py
msgid "CSS or xPath selector"
msgstr ""
#: changedetectionio/forms.py
msgid "value"
msgstr "值"
#: changedetectionio/conditions/form.py changedetectionio/forms.py
msgid "Value"
msgstr ""
#: changedetectionio/forms.py
msgid "Web Page URL"
msgstr ""
#: changedetectionio/forms.py
msgid "Group Tag"
msgstr ""
#: changedetectionio/forms.py
msgid "Time Between Check"
msgstr "檢查間隔"
@@ -2532,8 +2297,7 @@ msgstr "當文字符合時,阻擋變更檢測"
msgid "Execute JavaScript before change detection"
msgstr "在變更檢測前執行 JavaScript"
#: changedetectionio/blueprint/tags/form.py changedetectionio/blueprint/tags/templates/groups-overview.html
#: changedetectionio/forms.py
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py
msgid "Save"
msgstr "儲存"
@@ -2601,7 +2365,7 @@ msgstr "無效的範本語法:%(error)s"
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr "「%(header)s」標頭中的範本語法無效%(error)s"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
#: changedetectionio/forms.py
msgid "Name"
msgstr "名稱"
@@ -2697,10 +2461,6 @@ msgstr "忽略文字"
msgid "Ignore whitespace"
msgstr "忽略空白"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
msgstr "必須介於 0 到 100 之間"
@@ -2900,42 +2660,6 @@ msgstr "針對單一產品頁面的補貨與價格檢測"
msgid "Detects if the product goes back to in-stock"
msgstr "檢測產品是否恢復庫存"
#: changedetectionio/processors/templates/extract.html
msgid "Screenshot"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "This tool will extract text data from all of the watch history."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "For example, to extract only the numbers from text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Raw text"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "RegEx to extract:"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Be sure to test your RegEx here."
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "Each RegEx group bracket"
msgstr ""
#: changedetectionio/processors/templates/extract.html
msgid "will be in its own column, the first column value is always the date."
msgstr ""
#: changedetectionio/processors/text_json_diff/processor.py
msgid "Webpage Text/HTML, JSON and PDF changes"
msgstr "網頁文字 / HTML、JSON 和 PDF 變更"
@@ -3115,7 +2839,7 @@ msgstr ""
msgid "Use"
msgstr "使用"
#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/include_subtract.html
#: changedetectionio/templates/_common_fields.html
msgid "Show advanced help and tips"
msgstr "顯示進階說明與提示"
@@ -3219,26 +2943,6 @@ msgstr ""
msgid "Format for all notifications"
msgstr "所有通知的格式"
#: changedetectionio/templates/_common_fields.html
msgid "Note"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Discord does not render HTML — switch to"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Plain Text"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "format to avoid"
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "and other HTML entities appearing literally in your notifications."
msgstr ""
#: changedetectionio/templates/_helpers.html
msgid "Entry"
msgstr ""
@@ -3355,18 +3059,10 @@ msgstr "當文字符合時,阻擋變更檢測"
msgid "Blocked text"
msgstr "阻擋文字"
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
msgstr "搜尋,或使用 Alt+S 鍵"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
msgstr "離線即時更新"
@@ -3537,6 +3233,10 @@ msgstr ""
msgid "Unmute notifications"
msgstr "取消靜音通知"
#: changedetectionio/templates/menu.html
msgid "Mute notifications"
msgstr "靜音通知"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "通知已靜音 - 點擊以取消靜音"

View File

@@ -405,6 +405,74 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
try:
# Reset the edited flag BEFORE update_watch (which calls watch.update() and would set it again)
watch.reset_watch_edited_flag()
# LLM evaluation — intent filtering + change summary
update_obj['_llm_result'] = None
update_obj['_llm_intent'] = ''
update_obj['_llm_change_summary'] = ''
if changed_detected:
try:
from changedetectionio.llm.evaluator import (
evaluate_change, resolve_intent, resolve_llm_field,
summarise_change, get_llm_config,
get_effective_summary_prompt, compute_summary_cache_key,
)
_llm_cfg = get_llm_config(datastore)
if _llm_cfg:
# Compute unified diff once — used by both intent and summary
_watch_dates = list(watch.history.keys())
if _watch_dates:
_prev_text = watch.get_history_snapshot(timestamp=_watch_dates[-1]) or ''
from difflib import unified_diff as _unified_diff
_diff_lines = list(_unified_diff(
_prev_text.splitlines(keepends=True),
contents.splitlines(keepends=True),
lineterm='',
n=3
))
_diff_text = ''.join(_diff_lines) if _diff_lines else contents
else:
_diff_text = contents
# Step 1: AI Change Intent — may suppress notification
_llm_intent, _llm_intent_source = resolve_intent(watch, datastore)
if _llm_intent:
_llm_result = await loop.run_in_executor(
executor,
lambda diff=_diff_text, snap=contents: evaluate_change(
watch, datastore, diff=diff, current_snapshot=snap
)
)
update_obj['_llm_result'] = _llm_result
update_obj['_llm_intent'] = _llm_intent
if _llm_result and not _llm_result.get('important', True):
changed_detected = False
logger.info(
f"LLM filtered out change for {uuid} "
f"(intent from {_llm_intent_source}): "
f"{_llm_result.get('summary', '')[:80]}"
)
# Step 2: AI Change Summary — runs for any LLM-configured watch with a change
if changed_detected:
_effective_prompt = get_effective_summary_prompt(watch, datastore)
_summary_cache_key = compute_summary_cache_key(_diff_text, _effective_prompt)
_change_summary = await loop.run_in_executor(
executor,
lambda diff=_diff_text, snap=contents: summarise_change(
watch, datastore, diff=diff, current_snapshot=snap
)
)
if _change_summary:
update_obj['_llm_change_summary'] = _change_summary
try:
watch.save_llm_diff_summary(_change_summary, cache_key=_summary_cache_key)
except Exception as _fe:
logger.warning(f"Could not write last-llm-diff-summary.txt for {uuid}: {_fe}")
except Exception as e:
logger.warning(f"LLM evaluation error for {uuid}: {e}")
datastore.update_watch(uuid=uuid, update_obj=update_obj)
if changed_detected or not watch.history_n:

View File

@@ -140,6 +140,11 @@ tzdata
pluggy ~= 1.6
# LLM intent-based change evaluation (multi-provider via litellm)
litellm>=1.40.0
# BM25 relevance trimming for large snapshots (pure Python, no ML)
rank-bm25>=0.2.2
# Needed for testing, cross-platform for process and system monitoring
psutil==7.2.2