mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-06-27 02:51:11 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6700631be |
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
@@ -39,14 +39,14 @@ jobs:
|
||||
# Or if we are in a tagged release scenario.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }} || ${{ github.event.release.tag_name }} != ''
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Cache pip packages
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
||||
@@ -44,14 +44,14 @@ jobs:
|
||||
- platform: linux/arm64
|
||||
dockerfile: ./.github/test/Dockerfile-alpine
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Cache pip packages
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
|
||||
|
||||
@@ -7,12 +7,12 @@ jobs:
|
||||
lint-code:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Lint with Ruff
|
||||
run: |
|
||||
pip install ruff
|
||||
# Check for syntax errors and undefined names, and gettext misuse
|
||||
ruff check . --select E9,F63,F7,F82,INT
|
||||
# Check for syntax errors and undefined names
|
||||
ruff check . --select E9,F63,F7,F82
|
||||
# Complete check with errors treated as warnings
|
||||
ruff check . --exit-zero
|
||||
- name: Validate OpenAPI spec
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
lint-translations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Check .po files with msgfmt
|
||||
run: |
|
||||
sudo apt-get install -y gettext
|
||||
@@ -31,15 +31,6 @@ jobs:
|
||||
echo "Checking $f"
|
||||
msgfmt --check-format -o /dev/null "$f"
|
||||
done
|
||||
- name: Lint .pot template with dennis
|
||||
run: |
|
||||
pip install "$(grep -E '^dennis ?>=' requirements.txt)"
|
||||
dennis-cmd lint --strict changedetectionio/translations/messages.pot
|
||||
- name: Lint .po files with dennis
|
||||
run: |
|
||||
dennis-cmd lint --strict --excluderules=W302 changedetectionio/translations/*/LC_MESSAGES/messages.po
|
||||
# W302 (unchanged) is excluded due to high false-positive rate in this codebase:
|
||||
# many msgstrs intentionally match msgid (units like "AI", "LLM", and proper nouns).
|
||||
- name: Check translation catalog is up-to-date
|
||||
run: |
|
||||
pip install "$(grep -E '^babel==' requirements.txt)"
|
||||
@@ -56,7 +47,7 @@ jobs:
|
||||
lint-template-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Check for fragmented gettext calls in templates
|
||||
run: |
|
||||
python3 << 'PYEOF'
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@v6
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Cache pip packages
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-py${{ env.PYTHON_VERSION }}-${{ hashFiles('requirements.txt') }}
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -299,7 +299,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -497,7 +497,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -537,7 +537,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -567,7 +567,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -595,7 +595,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -640,7 +640,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -694,7 +694,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history and tags for upgrade testing
|
||||
|
||||
|
||||
@@ -7,19 +7,3 @@ repos:
|
||||
args: [--fix]
|
||||
# Fomrat
|
||||
- id: ruff-format
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: dennis-lint-pot
|
||||
name: dennis lint pot
|
||||
language: system
|
||||
entry: dennis-cmd lint --strict
|
||||
files: ^changedetectionio/translations/messages\.pot$
|
||||
pass_filenames: true
|
||||
|
||||
- id: dennis-lint-po
|
||||
name: dennis lint po
|
||||
language: system
|
||||
entry: dennis-cmd lint --strict --excluderules=W302
|
||||
files: ^changedetectionio/translations/\w+/LC_MESSAGES/messages\.po$
|
||||
pass_filenames: true
|
||||
|
||||
+1
-5
@@ -20,11 +20,10 @@ exclude = [
|
||||
select = [
|
||||
"B", # flake8-bugbear
|
||||
"B9",
|
||||
"C",
|
||||
"C",
|
||||
"E", # pycodestyle
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
"INT", # flake8-gettext
|
||||
"N", # pep8-naming
|
||||
"UP", # pyupgrade
|
||||
"W", # pycodestyle
|
||||
@@ -44,9 +43,6 @@ ignore = [
|
||||
[lint.mccabe]
|
||||
max-complexity = 12
|
||||
|
||||
[lint.flake8-gettext]
|
||||
extend-function-names = ["_l", "lazy_gettext", "pgettext", "npgettext"]
|
||||
|
||||
[format]
|
||||
indent-style = "space"
|
||||
quote-style = "preserve"
|
||||
|
||||
@@ -30,7 +30,7 @@ Stop drowning in noise. Connect any LLM (OpenAI, Gemini, Anthropic, Ollama and m
|
||||
|
||||
**AI change summaries** — instead of staring at a raw diff, your notification reads _"Price dropped from $89.99 to $67.00"_ or _"3 new products added to the listing"_. Works globally or per-watch, with full control over the prompt.
|
||||
|
||||
Works with any model you already pay for — GPT-4o-mini and Gemini Flash handle this well at fractions of a cent per check. Or run it entirely locally with **Ollama**, **vLLM**, **LM Studio**, or any **OpenAI-compatible self-hosted endpoint** — pick the *OpenAI-compatible (vLLM, LM Studio, llama.cpp)* option in the provider dropdown and point it at your server's `/v1` URL. Powered by [LiteLLM](https://github.com/BerriAI/litellm), giving you seamless access to [100+ supported providers and models](https://docs.litellm.ai/docs/providers).
|
||||
Works with any model you already pay for — GPT-4o-mini and Gemini Flash handle this well at fractions of a cent per check. Or run it entirely locally with Ollama. Powered by [LiteLLM](https://github.com/BerriAI/litellm), giving you seamless access to [100+ supported providers and models](https://docs.litellm.ai/docs/providers).
|
||||
|
||||
[<img src="./docs/LLM-change-summary.jpeg" style="max-width:100%;" alt="AI-powered website change detection — plain language change summaries and smart alert rules" title="AI website change detection with LLM change summaries and intelligent alert filtering" />](https://changedetection.io?src=github)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||
# Semver means never use .01, or 00. Should be .1.
|
||||
__version__ = '0.55.7'
|
||||
__version__ = '0.55.3'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -7,7 +7,7 @@ import threading
|
||||
from flask import request
|
||||
from . import auth
|
||||
|
||||
from . import validate_openapi_request, strip_internal_api_fields
|
||||
from . import validate_openapi_request
|
||||
|
||||
|
||||
class Tag(Resource):
|
||||
@@ -85,8 +85,7 @@ class Tag(Resource):
|
||||
# Create clean tag dict without Watch-specific fields
|
||||
clean_tag = {k: v for k, v in tag.items() if k not in watch_only_fields}
|
||||
|
||||
# Never expose `__`-prefixed transient/internal fields
|
||||
return strip_internal_api_fields(clean_tag)
|
||||
return clean_tag
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('deleteTag')
|
||||
@@ -114,9 +113,8 @@ class Tag(Resource):
|
||||
if not tag:
|
||||
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
|
||||
|
||||
# Make a mutable copy of request.json for modification.
|
||||
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
|
||||
json_data = strip_internal_api_fields(dict(request.json))
|
||||
# Make a mutable copy of request.json for modification
|
||||
json_data = dict(request.json)
|
||||
|
||||
# Validate notification_urls if provided
|
||||
if 'notification_urls' in json_data:
|
||||
@@ -164,8 +162,7 @@ class Tag(Resource):
|
||||
def post(self):
|
||||
"""Create a single tag/group."""
|
||||
|
||||
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
|
||||
json_data = strip_internal_api_fields(request.get_json())
|
||||
json_data = request.get_json()
|
||||
title = json_data.get("title",'').strip()
|
||||
|
||||
# Validate that only valid fields are provided
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
from changedetectionio.validate_url import is_safe_valid_url
|
||||
@@ -13,7 +12,7 @@ from flask_restful import abort, Resource
|
||||
from loguru import logger
|
||||
import copy
|
||||
|
||||
from . import validate_openapi_request, get_readonly_watch_fields, strip_internal_api_fields
|
||||
from . import validate_openapi_request, get_readonly_watch_fields
|
||||
from ..notification import valid_notification_formats
|
||||
from ..notification.handler import newline_re
|
||||
|
||||
@@ -104,31 +103,9 @@ class Watch(Resource):
|
||||
# attr .last_changed will check for the last written text snapshot on change
|
||||
watch['last_changed'] = watch_obj.last_changed
|
||||
watch['viewed'] = watch_obj.viewed
|
||||
watch['link'] = watch_obj.link
|
||||
watch['link'] = watch_obj.link,
|
||||
|
||||
# Resolved processor config: tag override wins over watch-level config (mirrors restock processor logic)
|
||||
import json
|
||||
_restock_path = os.path.join(watch_obj.data_dir, 'restock_diff.json') if watch_obj.data_dir else None
|
||||
restock_config = {}
|
||||
if _restock_path and os.path.isfile(_restock_path):
|
||||
try:
|
||||
with open(_restock_path, 'r', encoding='utf-8') as _f:
|
||||
restock_config = json.load(_f).get('restock_diff') or {}
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to read restock_diff.json for watch {uuid}: {e}")
|
||||
restock_source = 'watch'
|
||||
tags = self.datastore.data['settings']['application'].get('tags', {})
|
||||
for tag_uuid in (watch_obj.get('tags') or []):
|
||||
tag = tags.get(tag_uuid, {})
|
||||
if tag.get('overrides_watch'):
|
||||
restock_config = dict(tag.get('processor_config_restock_diff') or {})
|
||||
restock_source = f'tag:{tag_uuid}'
|
||||
break
|
||||
watch['processor_config_restock_diff'] = restock_config
|
||||
watch['processor_config_restock_diff_source'] = restock_source
|
||||
|
||||
# Never expose `__`-prefixed transient/internal fields (e.g. __check_status)
|
||||
return strip_internal_api_fields(watch)
|
||||
return watch
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('deleteWatch')
|
||||
@@ -189,10 +166,8 @@ class Watch(Resource):
|
||||
# Handle processor-config-* fields separately (save to JSON, not datastore)
|
||||
from changedetectionio import processors
|
||||
|
||||
# Make a mutable copy of request.json for modification.
|
||||
# Silently discard `__`-prefixed transient/internal keys — they are not part of the
|
||||
# public schema and must never be writable (e.g. clients that round-trip GET → PUT).
|
||||
json_data = strip_internal_api_fields(dict(request.json))
|
||||
# Make a mutable copy of request.json for modification
|
||||
json_data = dict(request.json)
|
||||
|
||||
# Extract and remove processor config fields from json_data
|
||||
processor_config_data = processors.extract_processor_config_from_form_data(json_data)
|
||||
@@ -279,28 +254,8 @@ class WatchSingleHistory(Resource):
|
||||
if request.args.get('html'):
|
||||
content = watch.get_fetched_html(timestamp)
|
||||
if content:
|
||||
# XSS mitigation (GHSA-cgj8-g98g-4p9x): this is an API endpoint, not a
|
||||
# browser-rendered view. The bytes ARE HTML (that's what the caller asked
|
||||
# for) but a programmatic client doesn't need text/html — and serving
|
||||
# text/html lets attacker-planted <script> in a monitored site execute
|
||||
# in our origin if someone opens the URL in a browser.
|
||||
#
|
||||
# text/plain + explicit utf-8 + nosniff = browser shows inert text,
|
||||
# sniffing can't re-classify it as HTML, an absent charset can't be
|
||||
# auto-detected as UTF-7 (an alternative XSS vector). API clients
|
||||
# still get the raw bytes — they don't care about Content-Type.
|
||||
response = make_response(content, 200)
|
||||
response.headers['Content-Type'] = 'text/plain; charset=utf-8'
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Include the timestamp in the download name so downloading multiple
|
||||
# snapshots doesn't collide. No extension — the stored bytes are
|
||||
# "whatever the fetcher captured" (HTML, JSON, XML, text…), so
|
||||
# claiming .html on the download would be a false content-type label
|
||||
# for non-HTML watches. The user/curl can rename if needed.
|
||||
# Strip to safe filename chars (timestamp is already validated as a
|
||||
# watch.history key — this is defense in depth against header injection).
|
||||
safe_ts = re.sub(r'[^0-9A-Za-z_-]', '', str(timestamp))[:32] or 'snapshot'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="snapshot-{safe_ts}"'
|
||||
response.mimetype = "text/html"
|
||||
else:
|
||||
response = make_response("No content found", 404)
|
||||
response.mimetype = "text/plain"
|
||||
@@ -467,8 +422,7 @@ class CreateWatch(Resource):
|
||||
def post(self):
|
||||
"""Create a single watch."""
|
||||
|
||||
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
|
||||
json_data = strip_internal_api_fields(request.get_json())
|
||||
json_data = request.get_json()
|
||||
url = json_data['url'].strip()
|
||||
|
||||
if not is_safe_valid_url(url):
|
||||
|
||||
@@ -133,43 +133,6 @@ def get_tag_schema_properties():
|
||||
"""
|
||||
return _resolve_schema_properties('Tag')
|
||||
|
||||
def strip_private_keys(data):
|
||||
"""
|
||||
Remove `__`-prefixed keys from a watch/tag dict at the API boundary.
|
||||
|
||||
These are transient in-memory fields (e.g. `__check_status` set by the worker to
|
||||
surface "Fetching page..." in the UI) and are not part of the public OpenAPI
|
||||
contract. They must never appear in GET responses (otherwise a client that
|
||||
round-trips GET → PUT trips the unknown-field validator), and must be silently
|
||||
discarded from incoming PUT/POST payloads.
|
||||
|
||||
Returns a new dict; the input is not mutated.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
return {k: v for k, v in data.items() if not (isinstance(k, str) and k.startswith('__'))}
|
||||
|
||||
|
||||
def strip_internal_api_fields(data):
|
||||
"""
|
||||
Strip both `__`-prefixed keys AND system-managed fields that aren't in the public
|
||||
OpenAPI spec (skip-cache hashes, LLM runtime state, processor-set status, etc.).
|
||||
|
||||
Use this at every public API boundary so GET responses and PUT/POST payloads agree
|
||||
on what's part of the contract. The set of system-managed fields lives in
|
||||
model/schema_utils.py:SYSTEM_MANAGED_NON_SPEC_FIELDS — extend it there, not here.
|
||||
|
||||
Returns a new dict; the input is not mutated.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
from changedetectionio.model.schema_utils import SYSTEM_MANAGED_NON_SPEC_FIELDS
|
||||
return {
|
||||
k: v for k, v in data.items()
|
||||
if not (isinstance(k, str) and (k.startswith('__') or k in SYSTEM_MANAGED_NON_SPEC_FIELDS))
|
||||
}
|
||||
|
||||
|
||||
def validate_openapi_request(operation_id):
|
||||
"""Decorator to validate incoming requests against OpenAPI spec."""
|
||||
def decorator(f):
|
||||
|
||||
@@ -3,16 +3,6 @@ from functools import wraps
|
||||
from flask import current_app, redirect, request
|
||||
from loguru import logger
|
||||
|
||||
# Endpoints exempt from auth when `shared_diff_access` is enabled.
|
||||
# Must be exact endpoint names — substring matching (GHSA-vwgh-2hvh-4xm5)
|
||||
# let the state-changing `/diff/<uuid>/extract` endpoints slip through
|
||||
# because their names share the `diff_history_page` prefix.
|
||||
SHARED_DIFF_READ_ONLY_ENDPOINTS = frozenset({
|
||||
'ui.ui_diff.diff_history_page',
|
||||
'ui.ui_diff.processor_asset',
|
||||
'ui.ui_diff.download_patch',
|
||||
})
|
||||
|
||||
def login_optionally_required(func):
|
||||
"""
|
||||
If password authentication is enabled, verify the user is logged in.
|
||||
@@ -30,7 +20,7 @@ def login_optionally_required(func):
|
||||
has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False)
|
||||
|
||||
# Permitted
|
||||
if request.endpoint in SHARED_DIFF_READ_ONLY_ENDPOINTS and datastore.data['settings']['application'].get('shared_diff_access'):
|
||||
if request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):
|
||||
return func(*args, **kwargs)
|
||||
elif request.method in flask_login.config.EXEMPT_METHODS:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@@ -75,7 +75,7 @@ class import_url_list(Importer):
|
||||
self.remaining_data = []
|
||||
self.remaining_data.append(url)
|
||||
|
||||
flash(gettext("{count} Imported from list in {duration}s, {skipped_count} Skipped.").format(count=good, duration=f"{time.time() - now:.2f}", skipped_count=len(self.remaining_data)))
|
||||
flash(gettext("{} Imported from list in {:.2f}s, {} Skipped.").format(good, time.time() - now, len(self.remaining_data)))
|
||||
|
||||
|
||||
class import_distill_io_json(Importer):
|
||||
@@ -136,7 +136,7 @@ class import_distill_io_json(Importer):
|
||||
self.new_uuids.append(new_uuid)
|
||||
good += 1
|
||||
|
||||
flash(gettext("{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped.").format(count=len(self.new_uuids), duration=f"{time.time() - now:.2f}", skipped_count=len(self.remaining_data)))
|
||||
flash(gettext("{} Imported from Distill.io in {:.2f}s, {} Skipped.").format(len(self.new_uuids), time.time() - now, len(self.remaining_data)))
|
||||
|
||||
|
||||
class import_xlsx_wachete(Importer):
|
||||
@@ -212,7 +212,7 @@ class import_xlsx_wachete(Importer):
|
||||
logger.error(e)
|
||||
flash(gettext("Error processing row number {}, check all cell data types are correct, row was skipped.").format(row_id), 'error')
|
||||
|
||||
flash(gettext("{count} imported from Wachete .xlsx in {duration}s").format(count=len(self.new_uuids), duration=f"{time.time() - now:.2f}"))
|
||||
flash(gettext("{} imported from Wachete .xlsx in {:.2f}s").format(len(self.new_uuids), time.time() - now))
|
||||
|
||||
|
||||
class import_xlsx_custom(Importer):
|
||||
@@ -293,4 +293,4 @@ class import_xlsx_custom(Importer):
|
||||
logger.error(e)
|
||||
flash(gettext("Error processing row number {}, check all cell data types are correct, row was skipped.").format(row_i), 'error')
|
||||
|
||||
flash(gettext("{count} imported from custom .xlsx in {duration}s").format(count=len(self.new_uuids), duration=f"{time.time() - now:.2f}"))
|
||||
flash(gettext("{} imported from custom .xlsx in {:.2f}s").format(len(self.new_uuids), time.time() - now))
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="tabs collapsable">
|
||||
<ul>
|
||||
<li class="tab" id=""><a href="#url-list">{{ _('URL List') }}</a></li>
|
||||
<li class="tab"><a href="#distill-io">Distill.io</a></li>
|
||||
<li class="tab"><a href="#distill-io">{{ _('Distill.io') }}</a></li>
|
||||
<li class="tab"><a href="#xlsx">{{ _('.XLSX & Wachete') }}</a></li>
|
||||
<li class="tab"><a href="{{url_for('backups.restore.restore')}}">{{ _('Backup Restore') }}</a></li>
|
||||
</ul>
|
||||
@@ -45,7 +45,6 @@
|
||||
<div class="tab-pane-inner" id="distill-io">
|
||||
<div class="pure-control-group">
|
||||
{{ _('Copy and Paste your Distill.io watch \'export\' file, this should be a JSON file.') }}<br>
|
||||
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
|
||||
{{ _('This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, the rest (including <code>schedule</code>) are ignored.')|safe }}
|
||||
<br>
|
||||
<p>
|
||||
@@ -104,7 +103,7 @@
|
||||
{% for n in range(4) %}
|
||||
<td><select name="custom_xlsx[col_type_{{n}}]">
|
||||
<option value="" style="color: #aaa"> -- {{ _('none') }} --</option>
|
||||
<option value="url">URL</option>
|
||||
<option value="url">{{ _('URL') }}</option>
|
||||
<option value="title">{{ _('Title') }}</option>
|
||||
<option value="include_filters">{{ _('CSS/xPath filter') }}</option>
|
||||
<option value="tag">{{ _('Group / Tag name(s)') }}</option>
|
||||
|
||||
@@ -10,15 +10,12 @@ from flask_babel import gettext
|
||||
|
||||
from changedetectionio.store import ChangeDetectionStore
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
from changedetectionio.model.LLMSettings import LLMSettings
|
||||
|
||||
|
||||
def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
from changedetectionio.llm.evaluator import is_llm_features_disabled
|
||||
from changedetectionio.blueprint.settings.llm import construct_llm_blueprint
|
||||
settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
|
||||
if not is_llm_features_disabled():
|
||||
from changedetectionio.blueprint.settings.llm import construct_llm_blueprint
|
||||
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
|
||||
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
|
||||
|
||||
@settings_blueprint.route("", methods=['GET', "POST"])
|
||||
@login_optionally_required
|
||||
@@ -33,12 +30,21 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
default = deepcopy(datastore.data['settings'])
|
||||
|
||||
# api_key is intentionally blanked on GET — PasswordField never re-renders
|
||||
# its value, and a blank submission preserves the stored key.
|
||||
default['llm'] = LLMSettings.model_validate(
|
||||
datastore.data['settings']['application'].get('llm') or {}
|
||||
).model_dump()
|
||||
default['llm']['api_key'] = ''
|
||||
# Pre-populate LLM sub-form fields from stored config (text fields only —
|
||||
# PasswordField for api_key is intentionally left blank on GET).
|
||||
_stored_llm = datastore.data['settings']['application'].get('llm') or {}
|
||||
default['llm'] = {
|
||||
'llm_model': _stored_llm.get('model', ''),
|
||||
'llm_api_base': _stored_llm.get('api_base', ''),
|
||||
'llm_change_summary_default': datastore.data['settings']['application'].get('llm_change_summary_default', ''),
|
||||
'llm_override_diff_with_summary': datastore.data['settings']['application'].get('llm_override_diff_with_summary', True),
|
||||
'llm_restock_use_fallback_extract': datastore.data['settings']['application'].get('llm_restock_use_fallback_extract', True),
|
||||
'llm_budget_action': datastore.data['settings']['application'].get('llm_budget_action', 'skip_llm'),
|
||||
'llm_thinking_budget': str(datastore.data['settings']['application'].get('llm_thinking_budget', 0)),
|
||||
'llm_max_summary_tokens': str(datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)),
|
||||
'llm_token_budget_month': _stored_llm.get('token_budget_month', 0),
|
||||
'llm_max_input_chars': _stored_llm.get('max_input_chars', 0),
|
||||
}
|
||||
|
||||
if datastore.proxy_list is not None:
|
||||
available_proxies = list(datastore.proxy_list.keys())
|
||||
@@ -89,43 +95,72 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
datastore.data['settings']['application'].update(app_update)
|
||||
|
||||
# LLM config lives under settings.application.llm.* (post update_31).
|
||||
# Hydrate the stored dict into LLMSettings, then merge form input over it.
|
||||
# WTForms field names match LLMSettings field names exactly, so both sides
|
||||
# of the merge use the same key shape.
|
||||
existing_llm = LLMSettings.model_validate(
|
||||
datastore.data['settings']['application'].get('llm') or {}
|
||||
# Save LLM config separately under settings.application.llm.
|
||||
# Token counters (tokens_total_cumulative, tokens_this_month, tokens_month_key)
|
||||
# are system-managed and must never be overwritten by form submissions.
|
||||
_LLM_PROTECTED_FIELDS = {
|
||||
'tokens_total_cumulative', 'tokens_this_month', 'tokens_month_key',
|
||||
'cost_usd_total_cumulative', 'cost_usd_this_month',
|
||||
}
|
||||
existing_llm = datastore.data['settings']['application'].get('llm') or {}
|
||||
preserved_counters = {k: v for k, v in existing_llm.items() if k in _LLM_PROTECTED_FIELDS}
|
||||
|
||||
llm_data = form.data.get('llm') or {}
|
||||
|
||||
# PasswordField never re-populates its value on GET, so the submitted value
|
||||
# is only non-empty when the user explicitly typed a new key.
|
||||
# If blank, preserve the existing key so a settings save doesn't accidentally clear it.
|
||||
submitted_api_key = (llm_data.get('llm_api_key') or '').strip()
|
||||
effective_api_key = submitted_api_key if submitted_api_key else existing_llm.get('api_key', '')
|
||||
|
||||
# Application-level LLM settings (survive provider changes)
|
||||
datastore.data['settings']['application']['llm_change_summary_default'] = (
|
||||
llm_data.get('llm_change_summary_default') or ''
|
||||
).strip()
|
||||
datastore.data['settings']['application']['llm_override_diff_with_summary'] = (
|
||||
bool(llm_data.get('llm_override_diff_with_summary', True))
|
||||
)
|
||||
datastore.data['settings']['application']['llm_restock_use_fallback_extract'] = (
|
||||
bool(llm_data.get('llm_restock_use_fallback_extract', True))
|
||||
)
|
||||
datastore.data['settings']['application']['llm_budget_action'] = (
|
||||
llm_data.get('llm_budget_action') or 'skip_llm'
|
||||
)
|
||||
datastore.data['settings']['application']['llm_thinking_budget'] = (
|
||||
int(llm_data.get('llm_thinking_budget') or 0)
|
||||
)
|
||||
datastore.data['settings']['application']['llm_max_summary_tokens'] = (
|
||||
int(llm_data.get('llm_max_summary_tokens') or 3000)
|
||||
)
|
||||
|
||||
llm_form_input = dict(form.data.get('llm') or {})
|
||||
# Monthly token budget — only save if env var is not set
|
||||
import os as _os
|
||||
if not _os.getenv('LLM_TOKEN_BUDGET_MONTH', '').strip():
|
||||
_budget = llm_data.get('llm_token_budget_month') or 0
|
||||
existing_llm['token_budget_month'] = int(_budget) if _budget else 0
|
||||
|
||||
# Empty IntegerField submissions come back as None from WTForms;
|
||||
# the schema declares those fields as strict `int`, so passing
|
||||
# them through would fail validation. Treat None like the
|
||||
# absent-key case: keep the stored value, don't merge.
|
||||
llm_form_input = {k: v for k, v in llm_form_input.items() if v is not None}
|
||||
# Max input chars — only save if env var is not set
|
||||
if not _os.getenv('LLM_MAX_INPUT_CHARS', '').strip():
|
||||
_max_chars = llm_data.get('llm_max_input_chars') or 0
|
||||
existing_llm['max_input_chars'] = int(_max_chars) if _max_chars else 0
|
||||
|
||||
# PasswordField never re-renders, so a blank submitted value means
|
||||
# "keep stored key" — drop it from the merge.
|
||||
if not (llm_form_input.get('api_key') or '').strip():
|
||||
llm_form_input.pop('api_key', None)
|
||||
|
||||
# Env-var overrides make these fields read-only in the UI — ignore form input.
|
||||
if os.getenv('LLM_TOKEN_BUDGET_MONTH', '').strip():
|
||||
llm_form_input.pop('token_budget_month', None)
|
||||
if os.getenv('LLM_MAX_INPUT_CHARS', '').strip():
|
||||
llm_form_input.pop('max_input_chars', None)
|
||||
|
||||
# System-managed counters must never come from the form.
|
||||
for protected in LLMSettings.PROTECTED_FIELDS:
|
||||
llm_form_input.pop(protected, None)
|
||||
|
||||
merged = LLMSettings.model_validate({**existing_llm.model_dump(), **llm_form_input})
|
||||
|
||||
# Clearing the model field strips only the provider-connection fields.
|
||||
# User toggles, budgets, prompts and system counters survive (matches /llm/clear).
|
||||
exclude = set(LLMSettings.CONNECTION_FIELDS) if not merged.model.strip() else None
|
||||
datastore.data['settings']['application']['llm'] = merged.model_dump(exclude=exclude)
|
||||
llm_config = {
|
||||
'model': (llm_data.get('llm_model') or '').strip(),
|
||||
'api_key': effective_api_key,
|
||||
'api_base': (llm_data.get('llm_api_base') or '').strip(),
|
||||
'token_budget_month': existing_llm.get('token_budget_month', 0),
|
||||
'max_input_chars': existing_llm.get('max_input_chars', 0),
|
||||
**preserved_counters,
|
||||
}
|
||||
# Only store if a model is set
|
||||
if llm_config['model']:
|
||||
datastore.data['settings']['application']['llm'] = llm_config
|
||||
else:
|
||||
# Remove model config but retain counters for historical record
|
||||
if preserved_counters:
|
||||
datastore.data['settings']['application']['llm'] = preserved_counters
|
||||
else:
|
||||
datastore.data['settings']['application'].pop('llm', None)
|
||||
|
||||
# Handle dynamic worker count adjustment
|
||||
old_worker_count = datastore.data['settings']['requests'].get('workers', 1)
|
||||
@@ -146,8 +181,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# Check CPU core availability and warn if worker count is high
|
||||
cpu_count = os.cpu_count()
|
||||
if cpu_count and new_worker_count >= (cpu_count * 0.9):
|
||||
flash(gettext("Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})").format(
|
||||
worker_count=new_worker_count, cpu_count=cpu_count), 'warning')
|
||||
flash(gettext("Warning: Worker count ({}) is close to or exceeds available CPU cores ({})").format(
|
||||
new_worker_count, cpu_count), 'warning')
|
||||
|
||||
result = worker_pool.adjust_async_worker_count(
|
||||
new_count=new_worker_count,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from flask import Blueprint, jsonify, redirect, url_for, flash
|
||||
from flask_babel import gettext
|
||||
@@ -11,44 +8,6 @@ from changedetectionio.store import ChangeDetectionStore
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
|
||||
|
||||
class _LiteLLMWarningCapture(logging.Handler):
|
||||
"""Capture warnings emitted on the 'LiteLLM' stdlib logger during a single call.
|
||||
|
||||
litellm.get_valid_models() catches HTTP/auth errors internally, logs a warning,
|
||||
and returns []. Without capturing that warning we can't tell the user *why*
|
||||
no models came back (bad key vs. offline vs. genuinely empty model list).
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__(level=logging.WARNING)
|
||||
self.messages = []
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
self.messages.append(record.getMessage())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _humanize_litellm_error(raw: str) -> str:
|
||||
# litellm warnings typically look like:
|
||||
# "Error getting valid models: Failed to get models: { 'error': { 'message': '...' } }"
|
||||
# Pull the inner provider message when present; otherwise trim the boilerplate.
|
||||
if not raw:
|
||||
return raw
|
||||
m = re.search(r'\{.*\}', raw, re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
body = json.loads(m.group(0))
|
||||
inner = (body.get('error') or {}).get('message') or body.get('message')
|
||||
if inner:
|
||||
return inner
|
||||
except Exception:
|
||||
pass
|
||||
cleaned = re.sub(r'^Error getting valid models:\s*', '', raw)
|
||||
cleaned = re.sub(r'^Failed to get models:\s*', '', cleaned).strip()
|
||||
return cleaned[:500]
|
||||
|
||||
|
||||
def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
llm_blueprint = Blueprint('llm', __name__)
|
||||
|
||||
@@ -56,7 +15,6 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
@login_optionally_required
|
||||
def llm_get_models():
|
||||
from flask import request
|
||||
from changedetectionio.validate_url import is_llm_api_base_safe
|
||||
provider = request.args.get('provider', '').strip()
|
||||
api_key = request.args.get('api_key', '').strip()
|
||||
api_base = request.args.get('api_base', '').strip()
|
||||
@@ -67,62 +25,24 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.debug("LLM model list: no provider specified, returning 400")
|
||||
return jsonify({'models': [], 'error': 'No provider specified'}), 400
|
||||
|
||||
ok, reason = is_llm_api_base_safe(api_base)
|
||||
if not ok:
|
||||
logger.warning(f"LLM model list refused: api_base failed SSRF check ({reason})")
|
||||
return jsonify({'models': [], 'error': reason}), 400
|
||||
|
||||
# Credential-exfiltration guard (GHSA-g36r-fm2p-87xm).
|
||||
# Only substitute the stored api_key when api_base matches the stored
|
||||
# api_base. If the caller pointed at a different destination, refuse —
|
||||
# otherwise a CSRF / unauthenticated request can ship the operator's
|
||||
# long-lived provider key (sent as Authorization: Bearer …) to an
|
||||
# attacker-controlled URL.
|
||||
stored_llm = datastore.data['settings']['application'].get('llm') or {}
|
||||
stored_api_base = (stored_llm.get('api_base') or '').strip()
|
||||
# Fall back to the stored key if the user hasn't typed one yet
|
||||
if not api_key:
|
||||
if api_base == stored_api_base:
|
||||
api_key = (stored_llm.get('api_key') or '')
|
||||
logger.debug("LLM model list: no api_key in request, using stored key (api_base matches saved)")
|
||||
elif api_base:
|
||||
logger.warning("LLM model list refused: api_base differs from saved config but no api_key supplied")
|
||||
return jsonify({'models': [], 'error': gettext(
|
||||
"api_key is required when api_base differs from the saved configuration. "
|
||||
"Refusing to send the stored API key to a different endpoint."
|
||||
)}), 400
|
||||
api_key = (datastore.data['settings']['application'].get('llm') or {}).get('api_key', '')
|
||||
logger.debug("LLM model list: no api_key in request, using stored key")
|
||||
|
||||
_PREFIXES = {'gemini': 'gemini/', 'ollama': 'ollama/', 'openrouter': 'openrouter/',
|
||||
'openai_compatible': 'openai/'}
|
||||
# vLLM / LM Studio / llama.cpp speak OpenAI's wire format — route through litellm's
|
||||
# 'openai' provider but keep the UI-level name distinct from cloud OpenAI.
|
||||
_LITELLM_PROVIDER = {'openai_compatible': 'openai'}
|
||||
_PREFIXES = {'gemini': 'gemini/', 'ollama': 'ollama/', 'openrouter': 'openrouter/'}
|
||||
prefix = _PREFIXES.get(provider, '')
|
||||
litellm_provider = _LITELLM_PROVIDER.get(provider, provider)
|
||||
|
||||
try:
|
||||
import litellm
|
||||
logger.debug(f"LLM model list: calling litellm.get_valid_models provider={provider!r} (litellm={litellm_provider!r}) api_base={api_base!r}")
|
||||
|
||||
capture = _LiteLLMWarningCapture()
|
||||
litellm_logger = logging.getLogger('LiteLLM')
|
||||
litellm_logger.addHandler(capture)
|
||||
try:
|
||||
raw = litellm.get_valid_models(
|
||||
check_provider_endpoint=True,
|
||||
custom_llm_provider=litellm_provider,
|
||||
api_key=api_key or None,
|
||||
api_base=api_base or None,
|
||||
) or []
|
||||
finally:
|
||||
litellm_logger.removeHandler(capture)
|
||||
|
||||
logger.debug(f"LLM model list: calling litellm.get_valid_models provider={provider!r} api_base={api_base!r}")
|
||||
raw = litellm.get_valid_models(
|
||||
check_provider_endpoint=True,
|
||||
custom_llm_provider=provider,
|
||||
api_key=api_key or None,
|
||||
api_base=api_base or None,
|
||||
) or []
|
||||
models = sorted({(m if m.startswith(prefix) else prefix + m) for m in raw})
|
||||
|
||||
if not models and capture.messages:
|
||||
err = _humanize_litellm_error(capture.messages[-1])
|
||||
logger.debug(f"LLM model list: 0 models, surfacing captured litellm warning: {err!r}")
|
||||
return jsonify({'models': [], 'error': err}), 400
|
||||
|
||||
logger.debug(f"LLM model list: got {len(models)} models for provider={provider!r}")
|
||||
return jsonify({'models': models, 'error': None})
|
||||
except Exception as e:
|
||||
@@ -133,75 +53,28 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
@llm_blueprint.route("/test", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def llm_test():
|
||||
from flask import request
|
||||
from changedetectionio.llm.client import completion
|
||||
from changedetectionio.validate_url import is_llm_api_base_safe
|
||||
|
||||
# Pull stored config as the fallback, then override with anything the
|
||||
# form-driven JS sent as query params. Lets users test config changes
|
||||
# without first hitting Save (matching how /settings/llm/models works).
|
||||
stored = datastore.data['settings']['application'].get('llm') or {}
|
||||
# Keep the raw request-supplied values around so we can detect whether
|
||||
# the caller explicitly steered api_base / api_key (credential-exfil guard below).
|
||||
req_api_key = (request.args.get('api_key') or '').strip()
|
||||
req_api_base = (request.args.get('api_base') or '').strip()
|
||||
stored_api_base = (stored.get('api_base') or '').strip()
|
||||
llm_cfg = {
|
||||
'model': (request.args.get('model') or stored.get('model', '')).strip(),
|
||||
'api_key': (req_api_key or stored.get('api_key', '')).strip(),
|
||||
'api_base': (req_api_base or stored_api_base).strip(),
|
||||
'provider_kind': (request.args.get('provider_kind') or stored.get('provider_kind', '')).strip(),
|
||||
'local_token_multiplier': request.args.get('local_token_multiplier') or stored.get('local_token_multiplier'),
|
||||
}
|
||||
model = llm_cfg['model']
|
||||
api_base = llm_cfg['api_base']
|
||||
llm_cfg = datastore.data['settings']['application'].get('llm') or {}
|
||||
model = llm_cfg.get('model', '').strip()
|
||||
api_base = llm_cfg.get('api_base', '') or ''
|
||||
|
||||
logger.debug(
|
||||
f"LLM connection test requested: model={model!r} api_base={api_base!r} "
|
||||
f"provider_kind={llm_cfg['provider_kind']!r} "
|
||||
f"source={'form' if request.args.get('model') else 'datastore'}"
|
||||
)
|
||||
logger.debug(f"LLM connection test requested: model={model!r} api_base={api_base!r}")
|
||||
|
||||
if not model:
|
||||
logger.error("LLM connection test failed: no model configured")
|
||||
logger.error("LLM connection test failed: no model configured in datastore")
|
||||
return jsonify({'ok': False, 'error': 'No model configured.'}), 400
|
||||
|
||||
ok, reason = is_llm_api_base_safe(api_base)
|
||||
if not ok:
|
||||
logger.warning(f"LLM connection test refused: api_base failed SSRF check ({reason})")
|
||||
return jsonify({'ok': False, 'error': reason}), 400
|
||||
|
||||
# Credential-exfiltration guard (GHSA-g36r-fm2p-87xm).
|
||||
# If the caller specified an api_base that differs from the saved one but
|
||||
# did NOT supply a matching api_key, refuse to substitute the stored key.
|
||||
# Otherwise a CSRF / unauthenticated request can route the operator's
|
||||
# long-lived provider key to an attacker-controlled endpoint.
|
||||
if req_api_base and req_api_base != stored_api_base and not req_api_key:
|
||||
logger.warning("LLM connection test refused: api_base differs from saved config but no api_key supplied")
|
||||
return jsonify({'ok': False, 'error': gettext(
|
||||
"api_key is required when api_base differs from the saved configuration. "
|
||||
"Refusing to send the stored API key to a different endpoint."
|
||||
)}), 400
|
||||
|
||||
try:
|
||||
logger.debug(f"LLM connection test: sending test prompt to model={model!r}")
|
||||
# Reuse the same multiplier path the production calls use, so cloud providers
|
||||
# stay on a small base cap (matching upstream's pre-existing behavior) and only
|
||||
# reasoning-capable endpoints (Ollama, openai_compatible) opt into the extra
|
||||
# headroom needed for chain-of-thought to complete.
|
||||
# Timeout: omit the override so the test inherits DEFAULT_TIMEOUT (60s, tunable
|
||||
# via LLM_TIMEOUT). A shorter test-only timeout falsely fails on cold-starting
|
||||
# cloud reasoning models (e.g. ollama.com hosting qwen3.5:397b takes ~60s on
|
||||
# first hit) even though the same call succeeds in production.
|
||||
from changedetectionio.llm.evaluator import apply_local_token_multiplier, get_llm_settings
|
||||
text, total_tokens, input_tokens, output_tokens = completion(
|
||||
model=model,
|
||||
messages=[{'role': 'user', 'content':
|
||||
'Respond with just the word: ready'}],
|
||||
'Reply with exactly five words confirming you are ready.'}],
|
||||
api_key=llm_cfg.get('api_key') or None,
|
||||
api_base=api_base or None,
|
||||
max_tokens=apply_local_token_multiplier(200, llm_cfg),
|
||||
debug=get_llm_settings(datastore).debug,
|
||||
timeout=20,
|
||||
max_tokens=200,
|
||||
)
|
||||
reply = text.strip()
|
||||
if not reply:
|
||||
@@ -224,30 +97,16 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.exception("LLM connection test full traceback:")
|
||||
return jsonify({'ok': False, 'error': str(e)}), 400
|
||||
|
||||
# Both clear endpoints accept POST only — GET would let an attacker fire them via
|
||||
# <img src="...">, wiping LLM configuration / cached summaries on a logged-in
|
||||
# operator's browser (GHSA-g36r-fm2p-87xm). Flask-WTF CSRFProtect enforces a
|
||||
# CSRF token on POST automatically; the template renders csrf_token() inside the
|
||||
# surrounding <form>.
|
||||
@llm_blueprint.route("/clear", methods=['POST'])
|
||||
@llm_blueprint.route("/clear", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def llm_clear():
|
||||
from changedetectionio.model.LLMSettings import LLMSettings
|
||||
logger.debug("LLM configuration cleared by user")
|
||||
# Read existing config, write back a dict that omits the connection fields —
|
||||
# so the saved dict no longer has model/api_key/api_base/etc.
|
||||
# Toggles, prompts, budgets and counters survive.
|
||||
settings = LLMSettings.model_validate(
|
||||
datastore.data['settings']['application'].get('llm') or {}
|
||||
)
|
||||
datastore.data['settings']['application']['llm'] = settings.model_dump(
|
||||
exclude=set(LLMSettings.CONNECTION_FIELDS)
|
||||
)
|
||||
datastore.data['settings']['application'].pop('llm', None)
|
||||
datastore.commit()
|
||||
flash(gettext("AI / LLM configuration removed."), 'notice')
|
||||
return redirect(url_for('settings.settings_page') + '#ai')
|
||||
|
||||
@llm_blueprint.route("/clear-summary-cache", methods=['POST'])
|
||||
@llm_blueprint.route("/clear-summary-cache", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def llm_clear_summary_cache():
|
||||
import glob
|
||||
@@ -263,7 +122,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not remove LLM summary cache file {f}: {e}")
|
||||
logger.info(f"LLM summary cache cleared: {count} file(s) removed")
|
||||
flash(gettext("AI summary cache cleared ({} file(s) removed).").format(count), 'notice')
|
||||
flash(gettext("AI summary cache cleared (%(n)s file(s) removed).", n=count), 'notice')
|
||||
return redirect(url_for('settings.settings_page') + '#ai')
|
||||
|
||||
return llm_blueprint
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
<li class="tab"><a href="#fetching">{{ _('Fetching') }}</a></li>
|
||||
<li class="tab"><a href="#filters">{{ _('Global Filters') }}</a></li>
|
||||
<li class="tab"><a href="#ui-options">{{ _('UI Options') }}</a></li>
|
||||
<li class="tab"><a href="#api">API</a></li>
|
||||
<li class="tab"><a href="#rss">RSS</a></li>
|
||||
<li class="tab"><a href="#api">{{ _('API') }}</a></li>
|
||||
<li class="tab"><a href="#rss">{{ _('RSS') }}</a></li>
|
||||
<li class="tab"><a href="{{ url_for('backups.create') }}">{{ _('Backups') }}</a></li>
|
||||
<li class="tab"><a href="#timedate">{{ _('Time & Date') }}</a></li>
|
||||
<li class="tab"><a href="#proxies">{{ _('CAPTCHA & Proxies') }}</a></li>
|
||||
@@ -34,9 +34,7 @@
|
||||
<li class="tab"><a href="#plugin-{{ tab.plugin_id }}">{{ tab.tab_label }}</a></li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if not llm_features_disabled %}
|
||||
<li class="tab"><a href="#ai">{{ _('AI / LLM') }}</a></li>
|
||||
{% endif %}
|
||||
<li class="tab"><a href="#info">{{ _('Info') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -396,9 +394,7 @@ nav
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if not llm_features_disabled %}
|
||||
{% include 'settings_llm_tab.html' %}
|
||||
{% endif %}
|
||||
<div class="tab-pane-inner" id="info">
|
||||
<p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>
|
||||
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
<div class="stab-overview-text">
|
||||
<strong>{{ _('Intent filtering') }}</strong>
|
||||
<p>{{ _('Each watch or tag can carry a plain-text intent — %(ex1)s or %(ex2)s. On every detected change the AI evaluates the diff against it and suppresses irrelevant noise.', ex1='<strong>"notify me only when the price drops"</strong>', ex2='<strong>"alert when the item goes out of stock"</strong>') | safe }}</p>
|
||||
<p><small>{{ _('Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very small models (≤3B) may misjudge numeric comparisons.',
|
||||
local='<code>qwen2.5:7b</code>',
|
||||
gpt='<code>gpt-4o-mini</code>',
|
||||
gemini='<code>gemini-2.0-flash</code>') | safe }}</small></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stab-overview-feature">
|
||||
@@ -69,17 +65,6 @@
|
||||
{% call stab_pane('provider') %}
|
||||
<p class="stab-section-title">{{ _('AI Provider') }}</p>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label></label>
|
||||
{{ form.llm.form.enabled() }}
|
||||
<label for="{{ form.llm.form.enabled.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.enabled.label.text }}
|
||||
</label>
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Master switch — when off, all AI lookups are skipped even if a provider is configured below.') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if not llm_env_configured and not (llm_config and llm_config.get('model')) %}
|
||||
<div class="stab-overview-disclaimer">
|
||||
<div class="stab-disclaimer-icon">⚠</div>
|
||||
@@ -115,37 +100,33 @@
|
||||
<label for="llm-provider">{{ _('Provider') }}</label>
|
||||
<select id="llm-provider" onchange="llmOnProviderChange(this.value)">
|
||||
<option value="">— {{ _('select a provider') }} —</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="gemini">Google (Gemini)</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<optgroup label="OpenAI">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openai_compatible">{{ _('OpenAI-compatible (vLLM, LM Studio, llama.cpp)') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="Anthropic">
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</optgroup>
|
||||
<optgroup label="Google">
|
||||
<option value="gemini">Google (Gemini)</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ _('Local / Self-hosted') }}">
|
||||
<option value="ollama">Ollama (local)</option>
|
||||
</optgroup>
|
||||
<optgroup label="OpenRouter">
|
||||
<option value="openrouter">OpenRouter (200+ models)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.llm.form.api_key) }}
|
||||
{{ render_field(form.llm.form.llm_api_key) }}
|
||||
<span class="pure-form-message-inline" id="llm-key-hint"></span>
|
||||
</div>
|
||||
<div class="pure-control-group" id="llm-base-group" style="display:none">
|
||||
{{ render_field(form.llm.form.api_base) }}
|
||||
{{ 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>
|
||||
|
||||
{# Hidden field carrying the dropdown selection so the backend knows when to apply
|
||||
reasoning-friendly token caps (Ollama and OpenAI-compatible endpoints, which commonly
|
||||
serve reasoning models that need headroom for chain-of-thought to complete). #}
|
||||
{{ form.llm.form.provider_kind() }}
|
||||
|
||||
<div class="pure-control-group" id="llm-local-advanced-group" style="display:none">
|
||||
<label for="{{ form.llm.form.local_token_multiplier.id }}">{{ form.llm.form.local_token_multiplier.label.text }}</label>
|
||||
{{ form.llm.form.local_token_multiplier() }}
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps.', default='5x') | safe }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group" id="llm-fetch-group" style="display:none">
|
||||
<label></label>
|
||||
<button type="button" id="llm-fetch-btn" class="pure-button button-xsmall" onclick="llmFetchModels()"
|
||||
@@ -163,7 +144,8 @@
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.llm.form.model,
|
||||
{{ render_field(form.llm.form.llm_model,
|
||||
readonly=True,
|
||||
placeholder=_("Enter API key and click 'Load available models'")) }}
|
||||
</div>
|
||||
|
||||
@@ -174,14 +156,9 @@
|
||||
✓ {{ _('AI / LLM configured:') }} {{ llm_config.get('model') }}
|
||||
</span>
|
||||
|
||||
{# data-method="POST" tells modal.js to POST with the CSRF token instead of
|
||||
navigating — GET previously allowed <img>-based CSRF wipe (GHSA-g36r-fm2p-87xm).
|
||||
Stays as <a> because we're inside the outer settings <form> — nested forms are
|
||||
invalid HTML, so modal.js builds a body-level hidden form for the POST. #}
|
||||
<a href="{{ url_for('settings.llm.llm_clear') }}"
|
||||
class="pure-button button-xsmall"
|
||||
style="background:#c0392b;color:#fff;"
|
||||
data-method="POST"
|
||||
data-requires-confirm
|
||||
data-confirm-type="danger"
|
||||
data-confirm-title="{{ _('Remove AI / LLM configuration?') }}"
|
||||
@@ -205,11 +182,9 @@
|
||||
|
||||
<div class="pure-control-group" style="margin-top:1.2em; padding-top:1em; border-top:1px solid rgba(128,128,128,0.15);">
|
||||
<label style="color:#888; font-size:0.85em;">{{ _('Cache') }}</label>
|
||||
{# See comment above on data-method="POST"+modal.js (GHSA-g36r-fm2p-87xm). #}
|
||||
<a href="{{ url_for('settings.llm.llm_clear_summary_cache') }}"
|
||||
class="pure-button button-xsmall"
|
||||
style="background:#7f8c8d;color:#fff;"
|
||||
data-method="POST"
|
||||
data-requires-confirm
|
||||
data-confirm-type="warning"
|
||||
data-confirm-title="{{ _('Clear all summary cache?') }}"
|
||||
@@ -220,17 +195,6 @@
|
||||
</a>
|
||||
<span class="pure-form-message-inline">{{ _('Removes all cached AI change summaries across all watches. They will be regenerated on the next check.') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label></label>
|
||||
{{ form.llm.form.debug() }}
|
||||
<label for="{{ form.llm.form.debug.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.debug.label.text }}
|
||||
</label>
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. Leave off in production — generates a lot of log volume.') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}{# llm_env_configured #}
|
||||
|
||||
{% if not llm_env_configured and not (llm_config and llm_config.get('model')) %}
|
||||
@@ -243,10 +207,10 @@
|
||||
<p class="stab-section-title">{{ _('Default AI Change Summary') }}</p>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.llm.form.change_summary_default) }}
|
||||
{{ render_field(form.llm.form.llm_change_summary_default) }}
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Used for all watches unless overridden by the watch or its tag/group.') }}
|
||||
<a href="#" class="pure-button button-small" onclick="var t=document.getElementById('llm-change_summary_default'); if(!t.value && t.placeholder) t.value=t.placeholder; return false;">{{ _('Modify default prompt') }}</a>
|
||||
<a href="#" class="pure-button button-small" onclick="var t=document.getElementById('llm-llm_change_summary_default'); if(!t.value && t.placeholder) t.value=t.placeholder; return false;">{{ _('Modify default prompt') }}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -259,9 +223,9 @@
|
||||
{% if llm_config and llm_config.get('model') %}
|
||||
<div class="pure-control-group">
|
||||
<label></label>
|
||||
{{ form.llm.form.override_diff_with_summary() }}
|
||||
<label for="{{ form.llm.form.override_diff_with_summary.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.override_diff_with_summary.label.text }}
|
||||
{{ form.llm.form.llm_override_diff_with_summary() }}
|
||||
<label for="{{ form.llm.form.llm_override_diff_with_summary.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.llm_override_diff_with_summary.label.text }}
|
||||
</label>
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('When enabled, the <code>%(diff)s</code> notification token shows the AI summary instead of the raw diff. Use <code>%(raw_diff)s</code> to always get the original.',
|
||||
@@ -271,9 +235,9 @@
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label></label>
|
||||
{{ form.llm.form.restock_use_fallback_extract() }}
|
||||
<label for="{{ form.llm.form.restock_use_fallback_extract.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.restock_use_fallback_extract.label.text }}
|
||||
{{ form.llm.form.llm_restock_use_fallback_extract() }}
|
||||
<label for="{{ form.llm.form.llm_restock_use_fallback_extract.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.llm_restock_use_fallback_extract.label.text }}
|
||||
</label>
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('When enabled, the AI will be used as a last resort to extract price and stock status from product pages where no structured metadata (JSON-LD, microdata, OpenGraph) is found.') }}
|
||||
@@ -281,21 +245,21 @@
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="{{ form.llm.form.thinking_budget.id }}">{{ form.llm.form.thinking_budget.label.text }}</label>
|
||||
{{ form.llm.form.thinking_budget() }}
|
||||
<label for="{{ form.llm.form.llm_thinking_budget.id }}">{{ form.llm.form.llm_thinking_budget.label.text }}</label>
|
||||
{{ form.llm.form.llm_thinking_budget() }}
|
||||
<span class="pure-form-message-inline">{{ _('For Gemini 2.5+ models only. Thinking tokens improve reasoning quality but count against the output budget. Set to Off if summaries are being cut short.') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="{{ form.llm.form.max_summary_tokens.id }}">{{ form.llm.form.max_summary_tokens.label.text }}</label>
|
||||
{{ form.llm.form.max_summary_tokens() }}
|
||||
<label for="{{ form.llm.form.llm_max_summary_tokens.id }}">{{ form.llm.form.llm_max_summary_tokens.label.text }}</label>
|
||||
{{ form.llm.form.llm_max_summary_tokens() }}
|
||||
<span class="pure-form-message-inline">{{ _('Upper limit on tokens the AI may use when writing a change summary. Higher values allow longer summaries but cost more.') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label>{{ form.llm.form.budget_action.label.text }}</label>
|
||||
<label>{{ form.llm.form.llm_budget_action.label.text }}</label>
|
||||
<div>
|
||||
{% for subfield in form.llm.form.budget_action %}
|
||||
{% for subfield in form.llm.form.llm_budget_action %}
|
||||
<label class="pure-radio" style="display:block; font-weight:normal; margin-bottom:0.3em;">
|
||||
{{ subfield() }} {{ subfield.label.text }}
|
||||
</label>
|
||||
@@ -348,9 +312,9 @@
|
||||
{% if llm_token_budget_month_env %}
|
||||
<strong>{{ '{:,}'.format(llm_token_budget_month_env) }}</strong>
|
||||
<span class="llm-env-badge">{{ _('(set via <code>LLM_TOKEN_BUDGET_MONTH</code>)') | safe }}</span>
|
||||
<input type="hidden" name="llm-token_budget_month" value="{{ llm_token_budget_month_env }}">
|
||||
<input type="hidden" name="llm-llm_token_budget_month" value="{{ llm_token_budget_month_env }}">
|
||||
{% else %}
|
||||
{{ form.llm.form.token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
|
||||
{{ form.llm.form.llm_token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
|
||||
<span class="llm-field-hint">{{ _('tokens (0 = unlimited)') }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -365,21 +329,14 @@
|
||||
<span class="llm-usage-row-label">{{ _('Max input characters') }}</span>
|
||||
<span class="llm-usage-row-value">
|
||||
{% if llm_max_input_chars_env %}
|
||||
{{ form.llm.form.max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
|
||||
{{ form.llm.form.llm_max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
|
||||
<span class="llm-env-badge">{{ _('(set via <code>LLM_MAX_INPUT_CHARS</code>)') | safe }}</span>
|
||||
{% else %}
|
||||
{{ form.llm.form.max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
|
||||
<span class="llm-field-hint">{{ _('characters — currently enforcing: %(limit)s', limit='{:,}'.format(llm_effective_max_input_chars)) }}</span>
|
||||
{{ form.llm.form.llm_max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
|
||||
<span class="llm-field-hint">{{ _('characters — currently enforcing: %(n)s', n='{:,}'.format(llm_effective_max_input_chars)) }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="llm-usage-row">
|
||||
<span class="llm-usage-row-label">{{ _('Max tokens per watch per period') }}</span>
|
||||
<span class="llm-usage-row-value">
|
||||
{{ form.llm.form.max_tokens_per_count_period(placeholder=_('0 = unlimited'), value=llm_stored.get('max_tokens_per_count_period', 0) or '') }}
|
||||
<span class="llm-field-hint">{{ _('tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = unlimited)') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
@@ -392,9 +349,9 @@
|
||||
{% if llm_token_budget_month_env %}
|
||||
<strong>{{ '{:,}'.format(llm_token_budget_month_env) }}</strong>
|
||||
<span class="llm-env-badge">{{ _('(set via <code>LLM_TOKEN_BUDGET_MONTH</code>)') | safe }}</span>
|
||||
<input type="hidden" name="llm-token_budget_month" value="{{ llm_token_budget_month_env }}">
|
||||
<input type="hidden" name="llm-llm_token_budget_month" value="{{ llm_token_budget_month_env }}">
|
||||
{% else %}
|
||||
{{ form.llm.form.token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
|
||||
{{ form.llm.form.llm_token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
|
||||
<span class="llm-field-hint">{{ _('tokens per month (0 = unlimited)') }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -403,21 +360,14 @@
|
||||
<span class="llm-usage-row-label">{{ _('Max input characters') }}</span>
|
||||
<span class="llm-usage-row-value">
|
||||
{% if llm_max_input_chars_env %}
|
||||
{{ form.llm.form.max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
|
||||
{{ form.llm.form.llm_max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
|
||||
<span class="llm-env-badge">{{ _('(set via <code>LLM_MAX_INPUT_CHARS</code>)') | safe }}</span>
|
||||
{% else %}
|
||||
{{ form.llm.form.max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
|
||||
<span class="llm-field-hint">{{ _('characters — currently enforcing: %(limit)s', limit='{:,}'.format(llm_effective_max_input_chars)) }}</span>
|
||||
{{ form.llm.form.llm_max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
|
||||
<span class="llm-field-hint">{{ _('characters — currently enforcing: %(n)s', n='{:,}'.format(llm_effective_max_input_chars)) }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="llm-usage-row">
|
||||
<span class="llm-usage-row-label">{{ _('Max tokens per watch per period') }}</span>
|
||||
<span class="llm-usage-row-value">
|
||||
{{ form.llm.form.max_tokens_per_count_period(placeholder=_('0 = unlimited'), value=llm_stored.get('max_tokens_per_count_period', 0) or '') }}
|
||||
<span class="llm-field-hint">{{ _('tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = unlimited)') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
@@ -427,15 +377,14 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', 'openai_compatible', 'openrouter'];
|
||||
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', 'openrouter'];
|
||||
const BASE_DEFAULTS = { ollama: 'http://localhost:11434' };
|
||||
const KEY_HINTS = {
|
||||
openai: '{{ _("platform.openai.com → API keys") }}',
|
||||
anthropic: '{{ _("console.anthropic.com → API keys") }}',
|
||||
gemini: '{{ _("aistudio.google.com → Get API key") }}',
|
||||
ollama: '{{ _("No API key needed for local Ollama") }}',
|
||||
openai_compatible: '{{ _("Bearer token for your self-hosted server (vLLM, LM Studio, etc.)") }}',
|
||||
openrouter: '{{ _("openrouter.ai → Keys") }}',
|
||||
openai: '{{ _("platform.openai.com → API keys") }}',
|
||||
anthropic: '{{ _("console.anthropic.com → API keys") }}',
|
||||
gemini: '{{ _("aistudio.google.com → Get API key") }}',
|
||||
ollama: '{{ _("No API key needed for local Ollama") }}',
|
||||
openrouter: '{{ _("openrouter.ai → Keys") }}',
|
||||
};
|
||||
|
||||
window.llmDisclaimerToggle = function (cb) {
|
||||
@@ -444,32 +393,20 @@
|
||||
};
|
||||
|
||||
window.llmOnProviderChange = function (provider) {
|
||||
const fetchGroup = document.getElementById('llm-fetch-group');
|
||||
const baseGroup = document.getElementById('llm-base-group');
|
||||
const modelSelGrp = document.getElementById('llm-model-select-group');
|
||||
const localAdvGrp = document.getElementById('llm-local-advanced-group');
|
||||
const baseField = document.querySelector('[name="llm-api_base"]');
|
||||
const kindField = document.querySelector('[name="llm-provider_kind"]');
|
||||
const hint = document.getElementById('llm-key-hint');
|
||||
const fetchGroup = document.getElementById('llm-fetch-group');
|
||||
const baseGroup = document.getElementById('llm-base-group');
|
||||
const modelSelGrp = document.getElementById('llm-model-select-group');
|
||||
const baseField = document.querySelector('[name="llm-llm_api_base"]');
|
||||
const hint = document.getElementById('llm-key-hint');
|
||||
|
||||
fetchGroup.style.display = LIVE_PROVIDERS.includes(provider) ? '' : 'none';
|
||||
|
||||
const needsBase = provider === 'ollama' || provider === 'openai_compatible';
|
||||
const needsBase = provider === 'ollama';
|
||||
baseGroup.style.display = needsBase ? '' : 'none';
|
||||
if (BASE_DEFAULTS[provider] !== undefined) {
|
||||
if (!baseField.value) baseField.value = BASE_DEFAULTS[provider];
|
||||
}
|
||||
|
||||
// Persist the dropdown selection so the backend can branch on provider kind
|
||||
// (self-hosted endpoints — 'ollama' and 'openai_compatible' — trigger the
|
||||
// local-multiplier code path; cloud providers do not).
|
||||
if (kindField) kindField.value = provider || '';
|
||||
|
||||
// Show the local-endpoint advanced settings (token multiplier) for self-hosted
|
||||
// endpoints. Cloud providers get the original tight caps and don't see this
|
||||
// section at all.
|
||||
if (localAdvGrp) localAdvGrp.style.display = (provider === 'ollama' || provider === 'openai_compatible') ? '' : 'none';
|
||||
|
||||
hint.textContent = KEY_HINTS[provider] || '';
|
||||
modelSelGrp.style.display = 'none';
|
||||
document.getElementById('llm-fetch-status').textContent = '';
|
||||
@@ -477,8 +414,8 @@
|
||||
|
||||
window.llmFetchModels = async function () {
|
||||
const provider = document.getElementById('llm-provider').value;
|
||||
const apiKey = document.querySelector('[name="llm-api_key"]').value.trim();
|
||||
const apiBase = document.querySelector('[name="llm-api_base"]').value.trim();
|
||||
const apiKey = document.querySelector('[name="llm-llm_api_key"]').value.trim();
|
||||
const apiBase = document.querySelector('[name="llm-llm_api_base"]').value.trim();
|
||||
const btn = document.getElementById('llm-fetch-btn');
|
||||
const statusEl = document.getElementById('llm-fetch-status');
|
||||
const selGroup = document.getElementById('llm-model-select-group');
|
||||
@@ -507,13 +444,13 @@
|
||||
|
||||
if (!data.models || data.models.length === 0) {
|
||||
statusEl.style.color = '#e67e22';
|
||||
statusEl.textContent = '{{ _("No models returned by the provider.") }}';
|
||||
statusEl.textContent = '{{ _("No models returned — check your API key.") }}';
|
||||
selGroup.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
modelSel.innerHTML = '<option value="">{{ _("— choose a model —") }}</option>';
|
||||
const currentModel = document.querySelector('[name="llm-model"]').value.trim();
|
||||
const currentModel = document.querySelector('[name="llm-llm_model"]').value.trim();
|
||||
for (const m of data.models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
@@ -535,7 +472,7 @@
|
||||
};
|
||||
|
||||
window.llmOnModelPick = function (value) {
|
||||
if (value) document.querySelector('[name="llm-model"]').value = value;
|
||||
if (value) document.querySelector('[name="llm-llm_model"]').value = value;
|
||||
};
|
||||
|
||||
window.llmRunTest = async function () {
|
||||
@@ -547,23 +484,8 @@
|
||||
btn.textContent = '⏳ {{ _("Testing…") }}';
|
||||
result.style.display = 'none';
|
||||
|
||||
// Send the form's current values so the user doesn't have to hit Save before
|
||||
// testing a config change. Endpoint falls back to the stored datastore values
|
||||
// for any field we don't send.
|
||||
const params = new URLSearchParams();
|
||||
const model = (document.querySelector('[name="llm-model"]') || {}).value || '';
|
||||
const apiKey = (document.querySelector('[name="llm-api_key"]') || {}).value || '';
|
||||
const apiBase = (document.querySelector('[name="llm-api_base"]') || {}).value || '';
|
||||
const kind = (document.querySelector('[name="llm-provider_kind"]') || {}).value || '';
|
||||
const mult = (document.querySelector('[name="llm-local_token_multiplier"]') || {}).value || '';
|
||||
if (model.trim()) params.set('model', model.trim());
|
||||
if (apiKey.trim()) params.set('api_key', apiKey.trim());
|
||||
if (apiBase.trim()) params.set('api_base', apiBase.trim());
|
||||
if (kind.trim()) params.set('provider_kind', kind.trim());
|
||||
if (mult.trim()) params.set('local_token_multiplier', mult.trim());
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}?' + params);
|
||||
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}');
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
result.style.cssText = 'display:block; background:rgba(39,174,96,0.08); border:1px solid rgba(39,174,96,0.3); border-radius:5px; padding:0.6em 0.85em; font-size:0.88em; line-height:1.45;';
|
||||
@@ -579,13 +501,13 @@
|
||||
result.innerHTML = '<span style="color:#c0392b; font-weight:600;">✗ {{ _("Request failed") }}</span>: ' + e.message.replace(/</g,'<');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '▶ {{ _("Test connection") }}';
|
||||
btn.textContent = '▶ {{ _("Test connection") }}';
|
||||
}
|
||||
};
|
||||
|
||||
// On page load: detect and pre-select provider from current model
|
||||
(function detectCurrentProvider() {
|
||||
const modelField = document.querySelector('[name="llm-model"]');
|
||||
const modelField = document.querySelector('[name="llm-llm_model"]');
|
||||
if (!modelField) return;
|
||||
const m = modelField.value.trim();
|
||||
if (!m) return;
|
||||
@@ -594,11 +516,6 @@
|
||||
if (m.startsWith('gemini/')) guessed = 'gemini';
|
||||
else if (m.startsWith('ollama/')) guessed = 'ollama';
|
||||
else if (m.startsWith('openrouter/')) guessed = 'openrouter';
|
||||
else if (m.startsWith('openai/')) {
|
||||
// openai/<model> + custom api_base = self-hosted OpenAI-compatible (vLLM etc.)
|
||||
const baseField = document.querySelector('[name="llm-api_base"]');
|
||||
guessed = (baseField && baseField.value.trim()) ? 'openai_compatible' : 'openai';
|
||||
}
|
||||
else if (m.startsWith('claude')) guessed = 'anthropic';
|
||||
else if (m.startsWith('gpt') || m.startsWith('o1') || m.startsWith('o3')) guessed = 'openai';
|
||||
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="tab-pane-inner" id="filters-and-triggers">
|
||||
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
|
||||
<p>{{ _('These settings are <strong><i>added</i></strong> to any existing watch configurations.')|safe }}</p>
|
||||
|
||||
{% include "edit/include_subtract.html" %}
|
||||
|
||||
@@ -307,8 +307,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
|
||||
# Provide feedback about skipped watches
|
||||
skipped_count = len(watches_to_queue) - len(watches_to_queue_filtered)
|
||||
if skipped_count > 0:
|
||||
flash(gettext("Queued {count} watches for rechecking ({skipped_count} already queued or running).").format(
|
||||
count=len(watches_to_queue_filtered), skipped_count=skipped_count))
|
||||
flash(gettext("Queued {} watches for rechecking ({} already queued or running).").format(
|
||||
len(watches_to_queue_filtered), skipped_count))
|
||||
else:
|
||||
if len(watches_to_queue_filtered) == 1:
|
||||
flash(gettext("Queued 1 watch for rechecking."))
|
||||
|
||||
@@ -198,12 +198,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
best_from = watch.get_from_version_based_on_last_viewed
|
||||
from_version = request.args.get('from_version', best_from if best_from else dates[-2])
|
||||
to_version = request.args.get('to_version', dates[-1])
|
||||
from changedetectionio.llm.evaluator import DiffPrefs
|
||||
prefs = DiffPrefs.from_request_args(request.args)
|
||||
all_changes = prefs.all_changes
|
||||
ignore_whitespace = prefs.ignore_whitespace
|
||||
show_removed = prefs.show_removed
|
||||
show_added = prefs.show_added
|
||||
all_changes = request.args.get('all_changes', '0') == '1'
|
||||
ignore_whitespace = request.args.get('ignore_whitespace', '0') == '1'
|
||||
show_removed = request.args.get('removed', '1') == '1'
|
||||
show_added = request.args.get('added', '1') == '1'
|
||||
|
||||
def _prep(text):
|
||||
"""Optionally normalise whitespace on each line before diffing."""
|
||||
@@ -265,22 +263,21 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
return jsonify({'summary': None, 'error': 'No differences found'})
|
||||
|
||||
from changedetectionio.llm.evaluator import (
|
||||
summarise_change, get_effective_summary_prompt, build_summary_cache_prompt,
|
||||
summarise_change, get_effective_summary_prompt,
|
||||
is_global_token_budget_exceeded, get_global_token_budget_month,
|
||||
LLMInputTooLargeError,
|
||||
)
|
||||
|
||||
# Diff-pref flags + system prompt + active model are part of the cache key
|
||||
# so prompt or model changes bust the cache.
|
||||
from changedetectionio.llm.evaluator import get_llm_settings
|
||||
_ls = get_llm_settings(datastore)
|
||||
_max_summary_tokens = _ls.max_summary_tokens
|
||||
_llm_model = _ls.model
|
||||
cache_prompt = build_summary_cache_prompt(
|
||||
effective_prompt=get_effective_summary_prompt(watch, datastore),
|
||||
max_summary_tokens=_max_summary_tokens,
|
||||
prefs=prefs,
|
||||
model=_llm_model,
|
||||
effective_prompt = get_effective_summary_prompt(watch, datastore)
|
||||
from changedetectionio.llm.prompt_builder import build_change_summary_system_prompt
|
||||
# Diff-pref flags + system prompt are part of the cache key so prompt changes bust the cache
|
||||
_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
|
||||
cache_prompt = (
|
||||
effective_prompt
|
||||
+ f'\x00prefs:all={int(all_changes)},ws={int(ignore_whitespace)}'
|
||||
f',rm={int(show_removed)},add={int(show_added)}'
|
||||
+ f'\x00sys:{build_change_summary_system_prompt()}'
|
||||
+ f'\x00max_tokens:{_max_summary_tokens}'
|
||||
)
|
||||
|
||||
# Check cache — keyed by version pair + prompt hash (invalidates if prompt changes)
|
||||
|
||||
@@ -57,9 +57,7 @@
|
||||
{% if capabilities.supports_visual_selector %}
|
||||
<li class="tab"><a id="visualselector-tab" href="#visualselector">{{ _('Visual Filter Selector') }}</a></li>
|
||||
{% endif %}
|
||||
{% if not llm_features_disabled %}
|
||||
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
|
||||
{% endif %}
|
||||
{% if capabilities.supports_text_filters_and_triggers %}
|
||||
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
|
||||
<li class="tab" id="conditions-tab"><a href="#conditions">{{ _('Conditions') }}</a></li>
|
||||
@@ -323,11 +321,9 @@ Math: {{ 1 + 1 }}") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not llm_features_disabled %}
|
||||
<div class="tab-pane-inner" id="ai-llm">
|
||||
{% include "edit/include_llm_intent.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="tab-pane-inner" id="filters-and-triggers">
|
||||
|
||||
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">{{ _('Activate preview') }}</span>
|
||||
@@ -369,7 +365,6 @@ Math: {{ 1 + 1 }}") }}
|
||||
</fieldset>
|
||||
<fieldset class="pure-control-group">
|
||||
{{ render_checkbox_field(form.sort_text_alphabetically) }}
|
||||
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
|
||||
<span class="pure-form-message-inline">{{ _('Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below.')|safe }}</span>
|
||||
</fieldset>
|
||||
<fieldset class="pure-control-group">
|
||||
@@ -507,7 +502,7 @@ Math: {{ 1 + 1 }}") }}
|
||||
<td>{{ _('Server type reply') }}</td>
|
||||
<td>{{ watch.get('remote_server_reply') }}</td>
|
||||
</tr>
|
||||
{% if not llm_features_disabled and settings_application.get('llm', {}).get('model') %}
|
||||
{% 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>
|
||||
|
||||
@@ -163,13 +163,12 @@ window.watchOverviewI18n = {
|
||||
data-confirm-type="danger"
|
||||
data-confirm-title="{{ _('Clear Histories') }}"
|
||||
data-confirm-message="{{ _('<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>') }}"
|
||||
{# TRANSLATORS: Universally recognized; typically left as-is. dennis-ignore: W302 #}
|
||||
data-confirm-button="{{ _('OK') }}"><i data-feather="trash-2" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Clear/reset history') }}</button>
|
||||
<button class="pure-button button-secondary button-xsmall" style="background: #dd4242;" name="op" value="delete"
|
||||
data-requires-confirm
|
||||
data-confirm-type="danger"
|
||||
data-confirm-title="{{ _('Delete Watches?') }}"
|
||||
data-confirm-message="{{ _('<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>') }}"
|
||||
data-confirm-message="{{ _('<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>') }}"
|
||||
data-confirm-button="{{ _('Delete') }}"><i data-feather="trash" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Delete') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -356,7 +355,7 @@ window.watchOverviewI18n = {
|
||||
{#last_checked becomes fetch-start-time#}
|
||||
<td class="last-checked" data-timestamp="{{ watch.last_checked }}" data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" data-label="{{ _('Last Checked') }}">
|
||||
<div class="spinner-wrapper" style="display:none;" >
|
||||
<span class="spinner"></span><span class="status-text"> {{ watch['__check_status'] or _('Checking now') }}</span>
|
||||
<span class="spinner"></span><span class="status-text"> {{ _('Checking now') }}</span>
|
||||
</div>
|
||||
<span class="innertext">{{watch|format_last_checked_time|safe}}</span>
|
||||
</td>
|
||||
|
||||
@@ -9,7 +9,7 @@ import asyncio
|
||||
from changedetectionio import strtobool
|
||||
from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
|
||||
from changedetectionio.content_fetchers.base import Fetcher
|
||||
from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
|
||||
from changedetectionio.validate_url import is_private_hostname
|
||||
|
||||
|
||||
# "html_requests" is listed as the default fetcher in store.py!
|
||||
@@ -87,12 +87,10 @@ class fetcher(Fetcher):
|
||||
|
||||
try:
|
||||
# Fresh DNS check at fetch time — catches DNS rebinding regardless of add-time cache.
|
||||
# Validates every hostname both urlparse and urllib3 see, so parser-differential
|
||||
# payloads (GHSA-rph4-96w6-q594) cannot smuggle an internal target past the gate.
|
||||
if not allow_iana_restricted:
|
||||
if is_url_private_or_parser_confused(url):
|
||||
raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address "
|
||||
f"or contains a parser-differential payload. "
|
||||
parsed_initial = urlparse(url)
|
||||
if parsed_initial.hostname and is_private_hostname(parsed_initial.hostname):
|
||||
raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address. "
|
||||
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow.")
|
||||
|
||||
r = session.request(method=request_method,
|
||||
@@ -113,9 +111,9 @@ class fetcher(Fetcher):
|
||||
location = r.headers.get('Location', '')
|
||||
redirect_url = urljoin(current_url, location)
|
||||
if not allow_iana_restricted:
|
||||
if is_url_private_or_parser_confused(redirect_url):
|
||||
raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address "
|
||||
f"or contains a parser-differential payload.")
|
||||
parsed_redirect = urlparse(redirect_url)
|
||||
if parsed_redirect.hostname and is_private_hostname(parsed_redirect.hostname):
|
||||
raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address.")
|
||||
current_url = redirect_url
|
||||
r = session.request('GET', redirect_url,
|
||||
headers=request_headers,
|
||||
|
||||
@@ -414,7 +414,7 @@ def _jinja2_filter_sanitize_tag_class(tag_title):
|
||||
return sanitized if sanitized else 'tag'
|
||||
|
||||
# Import login_optionally_required from auth_decorator
|
||||
from changedetectionio.auth_decorator import SHARED_DIFF_READ_ONLY_ENDPOINTS, login_optionally_required
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
|
||||
# When nobody is logged in Flask-Login's current_user is set to an AnonymousUser object.
|
||||
class User(flask_login.UserMixin):
|
||||
@@ -522,11 +522,6 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
available_languages=available_languages
|
||||
)
|
||||
|
||||
@app.context_processor
|
||||
def inject_llm_features_disabled():
|
||||
from changedetectionio.llm.evaluator import is_llm_features_disabled
|
||||
return dict(llm_features_disabled=is_llm_features_disabled())
|
||||
|
||||
# Set up a request hook to check authentication for all routes
|
||||
@app.before_request
|
||||
def check_authentication():
|
||||
@@ -546,7 +541,7 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
# Permitted
|
||||
elif request.endpoint and 'login' in request.endpoint:
|
||||
return None
|
||||
elif request.endpoint in SHARED_DIFF_READ_ONLY_ENDPOINTS and datastore.data['settings']['application'].get('shared_diff_access'):
|
||||
elif request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):
|
||||
return None
|
||||
elif request.method in flask_login.config.EXEMPT_METHODS:
|
||||
return None
|
||||
|
||||
+34
-101
@@ -17,7 +17,6 @@ from wtforms import (
|
||||
Form,
|
||||
Field,
|
||||
FloatField,
|
||||
HiddenField,
|
||||
IntegerField,
|
||||
PasswordField,
|
||||
RadioField,
|
||||
@@ -280,44 +279,12 @@ class TimeBetweenCheckForm(Form):
|
||||
return True
|
||||
|
||||
|
||||
class LabelAfterInputTableWidget(widgets.TableWidget):
|
||||
"""
|
||||
Variant of WTForms' TableWidget that renders the input cell before the label cell,
|
||||
so each row is <td>input</td><th>label</th> instead of the default <th>label</th><td>input</td>.
|
||||
"""
|
||||
|
||||
def __call__(self, field, **kwargs):
|
||||
from markupsafe import Markup
|
||||
from wtforms.widgets import html_params
|
||||
|
||||
html = []
|
||||
if self.with_table_tag:
|
||||
kwargs.setdefault("id", field.id)
|
||||
html.append(f"<table {html_params(**kwargs)}>")
|
||||
hidden = ""
|
||||
for subfield in field:
|
||||
if subfield.type in ("HiddenField", "CSRFTokenField"):
|
||||
hidden += str(subfield)
|
||||
else:
|
||||
html.append(
|
||||
f"<tr><td>{hidden}{subfield}</td><th>{subfield.label}</th></tr>"
|
||||
)
|
||||
hidden = ""
|
||||
if self.with_table_tag:
|
||||
html.append("</table>")
|
||||
if hidden:
|
||||
html.append(hidden)
|
||||
return Markup("".join(html))
|
||||
|
||||
|
||||
class EnhancedFormField(FormField):
|
||||
"""
|
||||
An enhanced FormField that supports conditional validation with top-level error messages.
|
||||
Adds a 'top_errors' property for validation errors at the FormField level.
|
||||
"""
|
||||
|
||||
widget = LabelAfterInputTableWidget()
|
||||
|
||||
def __init__(self, form_class, label=None, validators=None, separator="-",
|
||||
conditional_field=None, conditional_message=None, conditional_test_function=None, **kwargs):
|
||||
"""
|
||||
@@ -584,17 +551,6 @@ def validate_url(test_url):
|
||||
raise ValidationError('Watch protocol is not permitted or invalid URL format')
|
||||
|
||||
|
||||
class validateLLMApiBaseSafe(object):
|
||||
"""Block private/loopback/reserved api_base values (SSRF) unless the operator
|
||||
has opted in via ALLOW_IANA_RESTRICTED_ADDRESSES=true."""
|
||||
|
||||
def __call__(self, form, field):
|
||||
from changedetectionio.validate_url import is_llm_api_base_safe
|
||||
ok, reason = is_llm_api_base_safe(field.data)
|
||||
if not ok:
|
||||
raise ValidationError(reason)
|
||||
|
||||
|
||||
class ValidateSinglePythonRegexString(object):
|
||||
def __init__(self, message=None):
|
||||
self.message = message
|
||||
@@ -662,8 +618,8 @@ class ValidateCSSJSONXPATHInput(object):
|
||||
try:
|
||||
elementpath.select(tree, line.strip(), parser=SafeXPath3Parser)
|
||||
except elementpath.ElementPathError as e:
|
||||
message = field.gettext('\'%(expression)s\' is not a valid XPath expression. (%(error)s)')
|
||||
raise ValidationError(message % {'expression': line, 'error': str(e)})
|
||||
message = field.gettext('\'%s\' is not a valid XPath expression. (%s)')
|
||||
raise ValidationError(message % (line, str(e)))
|
||||
except:
|
||||
raise ValidationError("A system-error occurred when validating your XPath expression")
|
||||
|
||||
@@ -677,8 +633,8 @@ class ValidateCSSJSONXPATHInput(object):
|
||||
try:
|
||||
tree.xpath(line.strip())
|
||||
except etree.XPathEvalError as e:
|
||||
message = field.gettext('\'%(expression)s\' is not a valid XPath expression. (%(error)s)')
|
||||
raise ValidationError(message % {'expression': line, 'error': str(e)})
|
||||
message = field.gettext('\'%s\' is not a valid XPath expression. (%s)')
|
||||
raise ValidationError(message % (line, str(e)))
|
||||
except:
|
||||
raise ValidationError("A system-error occurred when validating your XPath expression")
|
||||
|
||||
@@ -697,8 +653,8 @@ class ValidateCSSJSONXPATHInput(object):
|
||||
try:
|
||||
parse(input)
|
||||
except (JsonPathParserError, JsonPathLexerError) as e:
|
||||
message = field.gettext('\'%(expression)s\' is not a valid JSONPath expression. (%(error)s)')
|
||||
raise ValidationError(message % {'expression': input, 'error': str(e)})
|
||||
message = field.gettext('\'%s\' is not a valid JSONPath expression. (%s)')
|
||||
raise ValidationError(message % (input, str(e)))
|
||||
except:
|
||||
raise ValidationError("A system-error occurred when validating your JSONPath expression")
|
||||
|
||||
@@ -721,8 +677,8 @@ class ValidateCSSJSONXPATHInput(object):
|
||||
validate_jq_expression(input)
|
||||
jq.compile(input)
|
||||
except (ValueError) as e:
|
||||
message = field.gettext('\'%(expression)s\' is not a valid jq expression. (%(error)s)')
|
||||
raise ValidationError(message % {'expression': input, 'error': str(e)})
|
||||
message = field.gettext('\'%s\' is not a valid jq expression. (%s)')
|
||||
raise ValidationError(message % (input, str(e)))
|
||||
except:
|
||||
raise ValidationError("A system-error occurred when validating your jq expression")
|
||||
|
||||
@@ -772,7 +728,7 @@ class ValidateStartsWithRegex(object):
|
||||
raise ValidationError(self.message or _l("Invalid value."))
|
||||
|
||||
class quickWatchForm(Form):
|
||||
url = StringField('URL', validators=[validateURL()])
|
||||
url = StringField(_l('URL'), validators=[validateURL()])
|
||||
tags = StringTagUUID(_l('Group tag'), validators=[validators.Optional()])
|
||||
watch_submit_button = SubmitField(_l('Watch'), render_kw={"class": "pure-button pure-button-primary"})
|
||||
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
|
||||
@@ -1101,54 +1057,34 @@ class globalSettingsLLMForm(Form):
|
||||
No separate provider dropdown needed — litellm routes automatically:
|
||||
gpt-4o-mini → OpenAI
|
||||
claude-3-5-haiku-20251001 → Anthropic
|
||||
ollama/llama3.2 → Ollama
|
||||
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
|
||||
"""
|
||||
model = StringField(
|
||||
llm_model = StringField(
|
||||
_l('Model'),
|
||||
validators=[validators.Optional()],
|
||||
render_kw={"placeholder": "gpt-4o-mini", "style": "width: 24em;"},
|
||||
)
|
||||
api_key = PasswordField(
|
||||
llm_api_key = PasswordField(
|
||||
_l('API Key'),
|
||||
validators=[validators.Optional()],
|
||||
render_kw={
|
||||
"placeholder": _l('Leave blank to use LITELLM_API_KEY env var'),
|
||||
"autocomplete": "off",
|
||||
"style": "width: 24em;",
|
||||
},
|
||||
)
|
||||
api_base = StringField(
|
||||
llm_api_base = StringField(
|
||||
_l('API Base URL'),
|
||||
validators=[validators.Optional(), validateLLMApiBaseSafe()],
|
||||
validators=[validators.Optional()],
|
||||
render_kw={
|
||||
"placeholder": "http://localhost:11434 (Ollama / custom endpoints only)",
|
||||
"style": "width: 24em;",
|
||||
},
|
||||
)
|
||||
# Persisted by the Provider dropdown JS — lets the backend distinguish a self-hosted
|
||||
# OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp) from cloud OpenAI, so we can
|
||||
# apply reasoning-friendly token caps only when the user opted in.
|
||||
provider_kind = HiddenField(
|
||||
validators=[validators.Optional()],
|
||||
default='',
|
||||
)
|
||||
# Multiplier applied to LLM max_tokens caps when provider_kind is 'ollama' or
|
||||
# 'openai_compatible' — endpoints that commonly serve reasoning models (Qwen3,
|
||||
# DeepSeek-R1, Gemma 3, etc.) which emit chain-of-thought into
|
||||
# message.reasoning_content before the final answer lands in message.content.
|
||||
# Cloud providers with non-reasoning defaults (OpenAI, Anthropic, Gemini,
|
||||
# OpenRouter) stay on the original tight caps so existing users see no
|
||||
# behavior or cost change. Users on paid Ollama / openai_compatible endpoints
|
||||
# who care about cost can dial this down to 1x.
|
||||
local_token_multiplier = IntegerField(
|
||||
_l('Token multiplier for local reasoning models'),
|
||||
validators=[validators.Optional(), validators.NumberRange(min=1, max=20)],
|
||||
default=5,
|
||||
render_kw={"placeholder": "5", "style": "width: 6em;"},
|
||||
)
|
||||
change_summary_default = TextAreaField(
|
||||
llm_change_summary_default = TextAreaField(
|
||||
_l('Default AI Change Summary prompt'),
|
||||
validators=[validators.Optional(), validators.Length(max=2000)],
|
||||
render_kw={
|
||||
@@ -1158,8 +1094,8 @@ class globalSettingsLLMForm(Form):
|
||||
},
|
||||
default='',
|
||||
)
|
||||
max_tokens_per_count_period = IntegerField(
|
||||
_l('Max tokens per watch per period'),
|
||||
llm_max_tokens_per_check = IntegerField(
|
||||
_l('Max tokens per check'),
|
||||
validators=[validators.Optional(), validators.NumberRange(min=0)],
|
||||
default=0,
|
||||
render_kw={
|
||||
@@ -1167,13 +1103,22 @@ class globalSettingsLLMForm(Form):
|
||||
"style": "width: 8em;",
|
||||
},
|
||||
)
|
||||
token_budget_month = IntegerField(
|
||||
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;",
|
||||
},
|
||||
)
|
||||
llm_token_budget_month = IntegerField(
|
||||
_l('Monthly token budget'),
|
||||
validators=[validators.Optional(), validators.NumberRange(min=0)],
|
||||
default=0,
|
||||
render_kw={"style": "width: 10em;"},
|
||||
)
|
||||
max_input_chars = IntegerField(
|
||||
llm_max_input_chars = IntegerField(
|
||||
_l('Max input characters'),
|
||||
validators=[validators.Optional(), validators.NumberRange(min=1)],
|
||||
default=100000,
|
||||
@@ -1182,27 +1127,15 @@ class globalSettingsLLMForm(Form):
|
||||
"style": "width: 10em;",
|
||||
},
|
||||
)
|
||||
# Master on/off switch for ALL LLM lookups at runtime. When False, every entry point
|
||||
# in evaluator.py (and the restock fallback) short-circuits with a logger.debug
|
||||
# message — even if a provider+model is still configured. Saved config and the
|
||||
# "configured" badge remain visible so the user can toggle back on without re-entering.
|
||||
enabled = BooleanField(
|
||||
_l('Enable AI / LLM features'),
|
||||
default=True,
|
||||
)
|
||||
override_diff_with_summary = BooleanField(
|
||||
llm_override_diff_with_summary = BooleanField(
|
||||
_l('Replace {{diff}} notification token with AI summary'),
|
||||
default=True,
|
||||
)
|
||||
restock_use_fallback_extract = BooleanField(
|
||||
llm_restock_use_fallback_extract = BooleanField(
|
||||
_l('Use LLM as a fallback for extracting price and restock info'),
|
||||
default=True,
|
||||
)
|
||||
debug = BooleanField(
|
||||
_l('Enable LLM debug logging'),
|
||||
default=False,
|
||||
)
|
||||
thinking_budget = SelectField(
|
||||
llm_thinking_budget = SelectField(
|
||||
_l('AI thinking budget (tokens)'),
|
||||
choices=[
|
||||
('0', _l('Off (no thinking)')),
|
||||
@@ -1213,7 +1146,7 @@ class globalSettingsLLMForm(Form):
|
||||
default=str(LLM_DEFAULT_THINKING_BUDGET),
|
||||
validators=[validators.Optional()],
|
||||
)
|
||||
max_summary_tokens = SelectField(
|
||||
llm_max_summary_tokens = SelectField(
|
||||
_l('Max AI summary length (tokens)'),
|
||||
choices=[
|
||||
('500', '500'),
|
||||
@@ -1226,7 +1159,7 @@ class globalSettingsLLMForm(Form):
|
||||
default=str(LLM_DEFAULT_MAX_SUMMARY_TOKENS),
|
||||
validators=[validators.Optional()],
|
||||
)
|
||||
budget_action = RadioField(
|
||||
llm_budget_action = RadioField(
|
||||
_l('When monthly token budget is reached'),
|
||||
choices=[
|
||||
('skip_llm', _l('Skip AI summarisation only (watch still checks)')),
|
||||
|
||||
@@ -757,41 +757,16 @@ def get_triggered_text(content, trigger_text):
|
||||
|
||||
|
||||
def extract_title(data: bytes | str, sniff_bytes: int = 2048, scan_chars: int = 8192) -> str | None:
|
||||
"""Extract the <title> from an HTML document.
|
||||
|
||||
Rather than decoding/scanning a fixed prefix of the whole document, we first
|
||||
locate the raw ``<title`` marker and then decode only a small window around
|
||||
it. This handles pages (e.g. Amazon) where large ``<head>`` sections push
|
||||
the title tag well past the old 8 192-character scan limit.
|
||||
"""
|
||||
# Maximum bytes/chars to extract after (and including) the opening <title tag.
|
||||
# The regex needs to see </title>, so the window must cover the full content.
|
||||
# The return value is always capped at 2 000 chars; titles beyond that are
|
||||
# rare but possible. We read up to 128 KiB from the tag onwards to handle
|
||||
# even pathological cases without scanning the whole document.
|
||||
_TITLE_WINDOW = 131072
|
||||
|
||||
try:
|
||||
# Only decode/process the prefix we need for title extraction
|
||||
match data:
|
||||
case bytes() if data.startswith((b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff")):
|
||||
# UTF-32: locate the tag in the raw bytes, then decode the window.
|
||||
tag_pos = data.lower().find(b"<\x00\x00\x00t\x00\x00\x00")
|
||||
if tag_pos == -1:
|
||||
return None
|
||||
chunk = data[tag_pos: tag_pos + _TITLE_WINDOW * 4].decode("utf-32", errors="replace")
|
||||
prefix = chunk
|
||||
case bytes() if data.startswith((b"\xff\xfe", b"\xfe\xff")):
|
||||
# UTF-16: simple byte-pair search is tricky; fall back to decoding
|
||||
# a reasonable head chunk and let the regex do the rest.
|
||||
prefix = data[: max(scan_chars * 2, _TITLE_WINDOW)].decode("utf-16", errors="replace")
|
||||
prefix = data[:scan_chars * 2].decode("utf-16", errors="replace")
|
||||
case bytes() if data.startswith((b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff")):
|
||||
prefix = data[:scan_chars * 4].decode("utf-32", errors="replace")
|
||||
case bytes():
|
||||
# UTF-8 / legacy 8-bit: find the tag cheaply in raw bytes.
|
||||
tag_pos = data.lower().find(b"<title")
|
||||
if tag_pos == -1:
|
||||
return None
|
||||
raw_chunk = data[tag_pos: tag_pos + _TITLE_WINDOW]
|
||||
try:
|
||||
chunk = raw_chunk.decode("utf-8")
|
||||
prefix = data[:scan_chars].decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
head = data[:sniff_bytes].decode("ascii", errors="ignore")
|
||||
@@ -799,27 +774,23 @@ def extract_title(data: bytes | str, sniff_bytes: int = 2048, scan_chars: int =
|
||||
enc = m.group(1).lower()
|
||||
else:
|
||||
enc = "cp1252"
|
||||
chunk = raw_chunk.decode(enc, errors="replace")
|
||||
prefix = data[:scan_chars * 2].decode(enc, errors="replace")
|
||||
except Exception as e:
|
||||
logger.error(f"Title extraction encoding detection failed: {e}")
|
||||
return None
|
||||
prefix = chunk
|
||||
case str():
|
||||
tag_pos = data.lower().find("<title")
|
||||
if tag_pos == -1:
|
||||
return None
|
||||
prefix = data[tag_pos: tag_pos + _TITLE_WINDOW]
|
||||
prefix = data[:scan_chars] if len(data) > scan_chars else data
|
||||
case _:
|
||||
logger.error(f"Title extraction received unsupported data type: {type(data)}")
|
||||
return None
|
||||
|
||||
# Search only in the (now tag-anchored) prefix
|
||||
# Search only in the prefix
|
||||
if m := TITLE_RE.search(prefix):
|
||||
title = html.unescape(" ".join(m.group(1).split())).strip()
|
||||
# Some safe limit
|
||||
return title[:2000]
|
||||
return None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Title extraction failed: {e}")
|
||||
return None
|
||||
@@ -4,7 +4,6 @@ Keeps litellm import isolated so the rest of the codebase doesn't depend on it d
|
||||
and makes the call easy to mock in tests.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from loguru import logger
|
||||
|
||||
@@ -18,46 +17,9 @@ DEFAULT_TIMEOUT = int(os.getenv('LLM_TIMEOUT', 60))
|
||||
DEFAULT_RETRIES = 3
|
||||
|
||||
|
||||
class _LoguruInterceptHandler(logging.Handler):
|
||||
# Routes litellm's stdlib log records through loguru so debug output
|
||||
# uses the same format/sink as the rest of the app.
|
||||
def emit(self, record):
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except (ValueError, AttributeError):
|
||||
level = record.levelno
|
||||
logger.opt(exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
_debug_installed = False
|
||||
|
||||
|
||||
def _install_litellm_debug():
|
||||
# Attach our loguru intercept and clear any pre-existing handlers so litellm's
|
||||
# own stdout StreamHandler (installed by _turn_on_debug / set_verbose) doesn't
|
||||
# double-emit. Setting the logger level to DEBUG is enough to make litellm
|
||||
# produce debug records — we don't call _turn_on_debug() for that reason.
|
||||
global _debug_installed
|
||||
if _debug_installed:
|
||||
return
|
||||
|
||||
handler = _LoguruInterceptHandler()
|
||||
handler.setLevel(logging.DEBUG)
|
||||
for _name in ('LiteLLM', 'litellm', 'litellm.utils', 'litellm.router'):
|
||||
_lg = logging.getLogger(_name)
|
||||
_lg.handlers = []
|
||||
_lg.setLevel(logging.DEBUG)
|
||||
_lg.addHandler(handler)
|
||||
_lg.propagate = False
|
||||
|
||||
_debug_installed = True
|
||||
logger.info("LLM client: litellm debug logging routed through loguru")
|
||||
|
||||
|
||||
def completion(model: str, messages: list, api_key: str = None,
|
||||
api_base: str = None, timeout: int = DEFAULT_TIMEOUT,
|
||||
max_tokens: int = None, extra_body: dict = None,
|
||||
debug: bool = False) -> tuple[str, int, int, int]:
|
||||
max_tokens: int = None, extra_body: dict = None) -> tuple[str, int, int, int]:
|
||||
"""
|
||||
Call the LLM and return (response_text, total_tokens, input_tokens, output_tokens).
|
||||
Retries up to DEFAULT_RETRIES times on timeout or connection errors.
|
||||
@@ -69,9 +31,6 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
except ImportError:
|
||||
raise RuntimeError("litellm is not installed. Add it to requirements.txt.")
|
||||
|
||||
if debug:
|
||||
_install_litellm_debug()
|
||||
|
||||
_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
|
||||
|
||||
kwargs = {
|
||||
@@ -90,12 +49,6 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
|
||||
_retryable = (litellm.Timeout, litellm.APIConnectionError)
|
||||
|
||||
logger.debug(
|
||||
f"LLM client: calling model={model!r} api_base={api_base!r} "
|
||||
f"timeout={_timeout}s max_tokens={kwargs['max_tokens']}"
|
||||
)
|
||||
logger.trace(messages)
|
||||
|
||||
for attempt in range(1, DEFAULT_RETRIES + 1):
|
||||
try:
|
||||
response = litellm.completion(**kwargs)
|
||||
|
||||
@@ -16,12 +16,9 @@ Environment variable overrides (take priority over datastore settings):
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from loguru import logger
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
|
||||
from . import client as llm_client
|
||||
from .prompt_builder import (
|
||||
build_change_summary_prompt, build_change_summary_system_prompt,
|
||||
@@ -31,29 +28,7 @@ from .prompt_builder import (
|
||||
)
|
||||
from .response_parser import parse_eval_response, parse_preview_response, parse_setup_response
|
||||
|
||||
from changedetectionio.model.LLMSettings import (
|
||||
LLMSettings,
|
||||
LLM_DEFAULT_MAX_INPUT_CHARS as _DEFAULT_MAX_INPUT_CHARS,
|
||||
LLM_DEFAULT_MAX_SUMMARY_TOKENS,
|
||||
LLM_DEFAULT_THINKING_BUDGET,
|
||||
)
|
||||
|
||||
|
||||
def is_llm_features_disabled() -> bool:
|
||||
"""True when the LLM_FEATURES_DISABLED env var is set to a truthy value."""
|
||||
return bool(strtobool(os.getenv('LLM_FEATURES_DISABLED', '')))
|
||||
|
||||
|
||||
def get_llm_settings(datastore) -> LLMSettings:
|
||||
"""Hydrate the LLM config dict at settings.application.llm into a validated model.
|
||||
|
||||
Returns a default-constructed LLMSettings when the dict is missing or empty —
|
||||
callers never have to None-check the result. The storage layer remains a plain
|
||||
dict; this is only the validation/typing layer for reads.
|
||||
"""
|
||||
cfg = datastore.data.get('settings', {}).get('application', {}).get('llm') or {}
|
||||
return LLMSettings.model_validate(cfg)
|
||||
|
||||
_DEFAULT_MAX_INPUT_CHARS = 100_000
|
||||
|
||||
def _get_max_input_chars(datastore) -> int:
|
||||
"""Max input characters to send to the LLM. Resolution: env var → datastore → 100,000.
|
||||
@@ -62,9 +37,10 @@ def _get_max_input_chars(datastore) -> int:
|
||||
env_val = os.getenv('LLM_MAX_INPUT_CHARS', '').strip()
|
||||
if env_val.isdigit() and int(env_val) > 0:
|
||||
return int(env_val)
|
||||
stored = get_llm_settings(datastore).max_input_chars
|
||||
if stored and stored > 0:
|
||||
return stored
|
||||
cfg = datastore.data.get('settings', {}).get('application', {}).get('llm') or {}
|
||||
stored = cfg.get('max_input_chars')
|
||||
if stored and int(stored) > 0:
|
||||
return int(stored)
|
||||
return _DEFAULT_MAX_INPUT_CHARS
|
||||
|
||||
|
||||
@@ -80,25 +56,14 @@ def _check_input_size(text: str, max_chars: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
LLM_DEFAULT_THINKING_BUDGET = 0 # 0 = thinking disabled by default
|
||||
|
||||
def _thinking_extra_body(model: str, budget: int) -> dict | None:
|
||||
"""Return litellm extra_body to control thinking for models that support it.
|
||||
|
||||
The `thinkingConfig.thinkingBudget` payload is Gemini-specific (Anthropic and
|
||||
OpenAI reasoning models use different parameters), so we gate on the gemini/
|
||||
provider prefix first, then defer to litellm's model registry for the actual
|
||||
"does this model think?" decision. That picks up new Gemini variants and
|
||||
rolling aliases (`gemini-flash-latest`, etc.) as litellm's registry tracks
|
||||
them, without us hardcoding model names here.
|
||||
For Gemini 2.5+: passes thinkingConfig with the given budget (0 = disabled).
|
||||
For all other models: returns None (no-op).
|
||||
"""
|
||||
if not model.startswith('gemini/'):
|
||||
return None
|
||||
try:
|
||||
import litellm
|
||||
if not litellm.get_model_info(model).get('supports_reasoning'):
|
||||
return None
|
||||
except Exception:
|
||||
# Unknown model or registry lookup failed — skip the thinking config
|
||||
# rather than guess. Worst case: thinking stays at the provider default.
|
||||
if not model.startswith('gemini/gemini-2.5'):
|
||||
return None
|
||||
return {'generationConfig': {'thinkingConfig': {'thinkingBudget': budget}}}
|
||||
|
||||
@@ -114,35 +79,10 @@ def _cached_system(text: str, model: str = '') -> dict:
|
||||
return {'role': 'system', 'content': text}
|
||||
|
||||
|
||||
# Output-token cap for the JSON-returning calls (intent eval, preview, setup/prefilter).
|
||||
# Mirrors client.py's _MAX_COMPLETION_TOKENS so the multiplier helper has a base value
|
||||
# to scale; cloud-LLM users hit this default unmodified, preserving prior cost defaults.
|
||||
JSON_RESPONSE_MAX_TOKENS = 400
|
||||
LLM_DEFAULT_MAX_SUMMARY_TOKENS = 3000
|
||||
|
||||
# Default prompt used when the user hasn't configured llm_change_summary.
|
||||
# This owns the OUTPUT FORMAT (structure, sections, style, language). The system prompt
|
||||
# in prompt_builder.build_change_summary_system_prompt() only covers how to READ the diff.
|
||||
# Users can replace this entirely (e.g. "Just tell me the new timestamp.") without
|
||||
# fighting hard-coded structure rules from the system prompt.
|
||||
DEFAULT_CHANGE_SUMMARY_PROMPT = (
|
||||
"Describe what changed in plain English using these sections, in this fixed order — "
|
||||
"omit a section entirely if there is nothing to report for it:\n"
|
||||
" Added: ...\n"
|
||||
" Changed: ...\n"
|
||||
" Removed: ...\n"
|
||||
"The Removed section MUST always be last. Never place removals before additions or changes.\n\n"
|
||||
"List items as bullet points with key details for each one. Be considerate of the style "
|
||||
"of content you are summarising and adjust your report accordingly.\n"
|
||||
"Do not list standalone timestamps like '3 hours ago', 'Yesterday', '2 minutes ago' as added "
|
||||
"or removed items — they are not meaningful content changes.\n"
|
||||
"For content-heavy pages (news, listings, feeds): quote or paraphrase the specific new "
|
||||
"headlines, items, or entries that were added — do not collapse them into vague phrases "
|
||||
"like 'new articles were added' or 'section was expanded'.\n"
|
||||
"For large blocks of new text (full articles, documents, long paragraphs): briefly summarise "
|
||||
"the substance in 1-2 sentences capturing the key point — do not just repeat the title.\n\n"
|
||||
"Do not quote non-English text verbatim; translate and summarise all content into English. "
|
||||
"Your entire response must be in English."
|
||||
)
|
||||
# Default prompt used when the user hasn't configured llm_change_summary
|
||||
DEFAULT_CHANGE_SUMMARY_PROMPT = "Describe in plain English what changed — list what was added or removed as bullet points, including key details for each item. Be careful of content that merely just moved around, you should mention that it moved but dont report that it was added/removed etc. Be considerate of the style content you are summarising the change of, adjust your report accordingly. Do not quote non-English text verbatim; translate and summarise all content into English. Your entire response must be in English."
|
||||
|
||||
|
||||
def _summary_max_tokens(diff: str, max_cap: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS) -> int:
|
||||
@@ -150,40 +90,6 @@ def _summary_max_tokens(diff: str, max_cap: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS
|
||||
return max(400, min(len(diff) // 4, max_cap))
|
||||
|
||||
|
||||
def apply_local_token_multiplier(base_max_tokens: int, llm_cfg: dict) -> int:
|
||||
"""
|
||||
Scale max_tokens for endpoints that commonly serve reasoning models
|
||||
(Ollama — self-hosted or ollama.com cloud — and OpenAI-compatible servers like
|
||||
vLLM, LM Studio, llama.cpp).
|
||||
|
||||
Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought into
|
||||
`message.reasoning_content` BEFORE the final answer lands in `message.content`.
|
||||
Without enough headroom the request truncates mid-thought (`finish_reason='length'`
|
||||
or `'stop'` with empty content) and the answer never lands — callers see an empty
|
||||
string and silently fall through to safe defaults, hiding the problem.
|
||||
|
||||
Cloud providers with stable, non-reasoning defaults (OpenAI, Anthropic, Gemini,
|
||||
OpenRouter) keep their original tight caps so existing users see no behavior or
|
||||
cost change. Ollama / OpenAI-compatible users can dial the multiplier down to 1x
|
||||
in Settings → AI → Provider if they want to keep costs tight on a paid endpoint.
|
||||
|
||||
Activated when `llm_cfg['provider_kind']` is `'ollama'` or `'openai_compatible'`.
|
||||
Multiplier defaults to 5x and is user-configurable in Settings → AI → Provider.
|
||||
"""
|
||||
if (llm_cfg or {}).get('provider_kind') not in ('ollama', 'openai_compatible'):
|
||||
return base_max_tokens
|
||||
try:
|
||||
multiplier = int(llm_cfg.get('local_token_multiplier') or 5)
|
||||
except (TypeError, ValueError):
|
||||
multiplier = 5
|
||||
# Clamp to the same 1-20 range the form enforces. Defense-in-depth against
|
||||
# corrupted datastore values that bypassed form validation (manual JSON edits,
|
||||
# future migrations, plugins): a runaway multiplier could otherwise produce
|
||||
# absurdly large max_tokens caps and exhaust local-endpoint memory.
|
||||
multiplier = max(1, min(multiplier, 20))
|
||||
return base_max_tokens * multiplier
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intent resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -239,8 +145,6 @@ def get_llm_config(datastore) -> dict | None:
|
||||
1. Environment variables: LLM_MODEL, LLM_API_KEY, LLM_API_BASE
|
||||
2. Datastore settings (set via UI)
|
||||
"""
|
||||
if is_llm_features_disabled():
|
||||
return None
|
||||
# 1. Environment variable override
|
||||
env_model = os.getenv('LLM_MODEL', '').strip()
|
||||
if env_model:
|
||||
@@ -259,33 +163,9 @@ def get_llm_config(datastore) -> dict | None:
|
||||
|
||||
def llm_configured_via_env() -> bool:
|
||||
"""True when LLM config comes from environment variables, not the UI."""
|
||||
if is_llm_features_disabled():
|
||||
return False
|
||||
return bool(os.getenv('LLM_MODEL', '').strip())
|
||||
|
||||
|
||||
def _runtime_llm_config(datastore) -> dict | None:
|
||||
"""
|
||||
Runtime gate used by every LLM entry point in this module (and the restock
|
||||
fallback). Returns the resolved config dict only when both:
|
||||
- the master 'llm_enabled' toggle is on (default True)
|
||||
- a provider+model is actually configured
|
||||
|
||||
When the toggle is off but a config exists, logs a debug message and returns
|
||||
None so callers fall through their existing "not configured" early-return path.
|
||||
|
||||
The settings UI deliberately still calls get_llm_config() directly so the
|
||||
"AI / LLM configured: ..." badge keeps showing the saved provider even while
|
||||
the toggle is off.
|
||||
"""
|
||||
cfg = get_llm_config(datastore)
|
||||
if not get_llm_settings(datastore).enabled:
|
||||
if cfg:
|
||||
logger.debug("LLM features disabled via settings (enabled=False) — skipping LLM lookup")
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global monthly token budget
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -356,22 +236,25 @@ def accumulate_global_tokens(datastore, tokens: int,
|
||||
|
||||
current_month = _get_month_key()
|
||||
cost = _estimate_cost_usd(model, input_tokens, output_tokens)
|
||||
settings = get_llm_settings(datastore)
|
||||
|
||||
# Work on the live dict in-place (or create a stub if llm key is absent)
|
||||
app_settings = datastore.data['settings']['application']
|
||||
if 'llm' not in app_settings:
|
||||
app_settings['llm'] = {}
|
||||
llm_cfg = app_settings['llm']
|
||||
|
||||
# Month rollover: reset monthly counters
|
||||
if settings.tokens_month_key != current_month:
|
||||
settings.tokens_this_month = 0
|
||||
settings.cost_usd_this_month = 0.0
|
||||
settings.tokens_month_key = current_month
|
||||
if llm_cfg.get('tokens_month_key') != current_month:
|
||||
llm_cfg['tokens_this_month'] = 0
|
||||
llm_cfg['cost_usd_this_month'] = 0.0
|
||||
llm_cfg['tokens_month_key'] = current_month
|
||||
|
||||
settings.tokens_total_cumulative += tokens
|
||||
settings.tokens_this_month += tokens
|
||||
settings.cost_usd_total_cumulative += cost
|
||||
settings.cost_usd_this_month += cost
|
||||
llm_cfg['tokens_total_cumulative'] = (llm_cfg.get('tokens_total_cumulative') or 0) + tokens
|
||||
llm_cfg['tokens_this_month'] = (llm_cfg.get('tokens_this_month') or 0) + tokens
|
||||
llm_cfg['cost_usd_total_cumulative'] = (llm_cfg.get('cost_usd_total_cumulative') or 0.0) + cost
|
||||
llm_cfg['cost_usd_this_month'] = (llm_cfg.get('cost_usd_this_month') or 0.0) + cost
|
||||
|
||||
# Round-trip through model_dump so storage stays a plain dict and the schema
|
||||
# contract (extra='forbid', type coercion) is re-enforced on every write.
|
||||
datastore.data['settings']['application']['llm'] = settings.model_dump()
|
||||
# Persist immediately — token accounting must survive restarts
|
||||
datastore.commit()
|
||||
|
||||
|
||||
@@ -399,44 +282,31 @@ def is_global_token_budget_exceeded(datastore) -> bool:
|
||||
|
||||
def _check_token_budget(watch, cfg, tokens_this_call: int = 0) -> bool:
|
||||
"""
|
||||
Per-watch per-period token cap.
|
||||
|
||||
Period is currently month (matches the global counter rollover); the field
|
||||
name `max_tokens_per_count_period` is period-agnostic so a configurable
|
||||
day/week/month can land later without renaming storage.
|
||||
|
||||
On non-zero tokens_this_call:
|
||||
- rolls over watch['llm_tokens_this_period'] if a new period started
|
||||
- increments the per-period counter
|
||||
- also increments the existing lifetime counter (UI stat, unchanged)
|
||||
Returns False once the per-period counter exceeds max_tokens_per_count_period
|
||||
so subsequent evaluate_change calls bail out for this watch until rollover.
|
||||
|
||||
Note: only evaluate_change actually gates on the return value (the other
|
||||
callers invoke this for the side-effect of accumulating tokens).
|
||||
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_period = _get_month_key()
|
||||
# Rollover: new period zeroes the per-period counter
|
||||
if watch.get('llm_tokens_period_key') != current_period:
|
||||
watch['llm_tokens_this_period'] = 0
|
||||
watch['llm_tokens_period_key'] = current_period
|
||||
watch['llm_tokens_this_period'] = (watch.get('llm_tokens_this_period') or 0) + tokens_this_call
|
||||
# Informational lifetime counter (UI shows this; not used for the cap)
|
||||
watch['llm_tokens_used_cumulative'] = (watch.get('llm_tokens_used_cumulative') or 0) + tokens_this_call
|
||||
current = watch.get('llm_tokens_used_cumulative') or 0
|
||||
watch['llm_tokens_used_cumulative'] = current + tokens_this_call
|
||||
|
||||
max_per_period = int(cfg.get('max_tokens_per_count_period') or 0)
|
||||
if max_per_period:
|
||||
# Pre-flight (tokens_this_call=0) and post-call paths both read the
|
||||
# same counter — but a stale period key means "no usage yet this period".
|
||||
if watch.get('llm_tokens_period_key') == _get_month_key():
|
||||
total = watch.get('llm_tokens_this_period') or 0
|
||||
if total > max_per_period:
|
||||
logger.warning(
|
||||
f"LLM per-period token budget exceeded for {watch.get('uuid')}: "
|
||||
f"{total} tokens > limit {max_per_period}"
|
||||
)
|
||||
return False
|
||||
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
|
||||
|
||||
@@ -447,7 +317,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
|
||||
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 = _runtime_llm_config(datastore)
|
||||
cfg = get_llm_config(datastore)
|
||||
if not cfg:
|
||||
return
|
||||
|
||||
@@ -458,7 +328,6 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
|
||||
url = watch.get('url', '')
|
||||
system_prompt = build_setup_system_prompt()
|
||||
user_prompt = build_setup_prompt(intent, snapshot_text, url=url)
|
||||
settings = get_llm_settings(datastore)
|
||||
|
||||
try:
|
||||
raw, tokens, *_ = llm_client.completion(
|
||||
@@ -469,9 +338,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
|
||||
],
|
||||
api_key=cfg.get('api_key'),
|
||||
api_base=cfg.get('api_base'),
|
||||
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
|
||||
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
|
||||
debug=settings.debug,
|
||||
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
|
||||
)
|
||||
_check_token_budget(watch, cfg, tokens)
|
||||
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
|
||||
@@ -495,7 +362,11 @@ def get_effective_summary_prompt(watch, datastore) -> str:
|
||||
prompt, _ = resolve_llm_field(watch, datastore, 'llm_change_summary')
|
||||
if prompt:
|
||||
return prompt
|
||||
global_default = get_llm_settings(datastore).change_summary_default.strip()
|
||||
global_default = (
|
||||
datastore.data.get('settings', {})
|
||||
.get('application', {})
|
||||
.get('llm_change_summary_default', '') or ''
|
||||
).strip()
|
||||
return global_default or DEFAULT_CHANGE_SUMMARY_PROMPT
|
||||
|
||||
|
||||
@@ -508,63 +379,6 @@ def compute_summary_cache_key(diff_text: str, prompt: str) -> str:
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiffPrefs:
|
||||
"""
|
||||
User-facing diff display preferences. Part of the LLM summary cache key so
|
||||
that toggling a preference produces a fresh summary.
|
||||
|
||||
Field defaults are the single source of truth — the UI query-arg defaults in
|
||||
diff.py's from_request_args() and the worker pre-cache's bare DiffPrefs()
|
||||
both rely on these.
|
||||
"""
|
||||
all_changes: bool = False
|
||||
ignore_whitespace: bool = False
|
||||
show_removed: bool = True
|
||||
show_added: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_request_args(cls, args) -> 'DiffPrefs':
|
||||
"""Parse from a Flask request.args (or any .get(key, default)-shaped mapping)."""
|
||||
return cls(
|
||||
all_changes = args.get('all_changes', '0') == '1',
|
||||
ignore_whitespace = args.get('ignore_whitespace', '0') == '1',
|
||||
show_removed = args.get('removed', '1') == '1',
|
||||
show_added = args.get('added', '1') == '1',
|
||||
)
|
||||
|
||||
def cache_key_suffix(self) -> str:
|
||||
return (
|
||||
f'\x00prefs:all={int(self.all_changes)},ws={int(self.ignore_whitespace)}'
|
||||
f',rm={int(self.show_removed)},add={int(self.show_added)}'
|
||||
)
|
||||
|
||||
|
||||
def build_summary_cache_prompt(effective_prompt: str, max_summary_tokens: int,
|
||||
prefs: DiffPrefs = None, model: str = '') -> str:
|
||||
"""
|
||||
Compose the full cache-key string passed to save/get_llm_diff_summary.
|
||||
|
||||
Default prefs are DiffPrefs() — must match the UI's query-arg defaults so a
|
||||
worker-side pre-cache is hit by an unmodified UI request. Same helper must
|
||||
be used by both the worker pre-cache write and the UI diff route read,
|
||||
otherwise the prompt hashes diverge and the cache file isn't found.
|
||||
|
||||
The active model name is folded into the key so switching models
|
||||
(e.g. qwen3 → gpt-4o) invalidates stale summaries that were generated
|
||||
by a different model with potentially different phrasing/quality.
|
||||
"""
|
||||
if prefs is None:
|
||||
prefs = DiffPrefs()
|
||||
return (
|
||||
effective_prompt
|
||||
+ prefs.cache_key_suffix()
|
||||
+ f'\x00sys:{build_change_summary_system_prompt()}'
|
||||
+ f'\x00max_tokens:{max_summary_tokens}'
|
||||
+ f'\x00model:{model}'
|
||||
)
|
||||
|
||||
|
||||
def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') -> str:
|
||||
"""
|
||||
Generate a plain-language summary of the change using the watch's
|
||||
@@ -574,7 +388,7 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
The result replaces {{ diff }} in notifications so the user gets a
|
||||
readable description instead of raw +/- diff lines.
|
||||
"""
|
||||
cfg = _runtime_llm_config(datastore)
|
||||
cfg = get_llm_config(datastore)
|
||||
if not cfg:
|
||||
return ''
|
||||
|
||||
@@ -605,8 +419,8 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
title=title,
|
||||
)
|
||||
|
||||
settings = get_llm_settings(datastore)
|
||||
_extra_body = _thinking_extra_body(cfg['model'], settings.thinking_budget)
|
||||
_thinking_budget = int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)
|
||||
_extra_body = _thinking_extra_body(cfg['model'], _thinking_budget)
|
||||
|
||||
try:
|
||||
_resp = llm_client.completion(
|
||||
@@ -617,12 +431,11 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
],
|
||||
api_key=cfg.get('api_key'),
|
||||
api_base=cfg.get('api_base'),
|
||||
max_tokens=apply_local_token_multiplier(
|
||||
_summary_max_tokens(diff, max_cap=settings.max_summary_tokens),
|
||||
cfg,
|
||||
max_tokens=_summary_max_tokens(
|
||||
diff,
|
||||
max_cap=int(datastore.data['settings']['application'].get('llm_max_summary_tokens', LLM_DEFAULT_MAX_SUMMARY_TOKENS) or LLM_DEFAULT_MAX_SUMMARY_TOKENS),
|
||||
),
|
||||
extra_body=_extra_body,
|
||||
debug=settings.debug,
|
||||
)
|
||||
raw, tokens = _resp[0], _resp[1]
|
||||
input_tokens = _resp[2] if len(_resp) > 2 else 0
|
||||
@@ -659,7 +472,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
|
||||
|
||||
Returns {'found': bool, 'answer': str} or None if LLM not configured / no intent.
|
||||
"""
|
||||
cfg = _runtime_llm_config(datastore)
|
||||
cfg = get_llm_config(datastore)
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
@@ -673,7 +486,6 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
|
||||
|
||||
system_prompt = build_preview_system_prompt()
|
||||
user_prompt = build_preview_prompt(intent, content, url=url, title=title)
|
||||
settings = get_llm_settings(datastore)
|
||||
|
||||
try:
|
||||
raw, tokens, *_ = llm_client.completion(
|
||||
@@ -684,9 +496,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
|
||||
],
|
||||
api_key=cfg.get('api_key'),
|
||||
api_base=cfg.get('api_base'),
|
||||
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
|
||||
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
|
||||
debug=settings.debug,
|
||||
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
|
||||
)
|
||||
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
|
||||
result = parse_preview_response(raw)
|
||||
@@ -711,7 +521,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
|
||||
Results are cached by (intent, diff) hash — each unique diff is evaluated exactly once.
|
||||
"""
|
||||
cfg = _runtime_llm_config(datastore)
|
||||
cfg = get_llm_config(datastore)
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
@@ -760,7 +570,6 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
title=title,
|
||||
)
|
||||
|
||||
settings = get_llm_settings(datastore)
|
||||
try:
|
||||
_resp = llm_client.completion(
|
||||
model=cfg['model'],
|
||||
@@ -770,9 +579,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
],
|
||||
api_key=cfg.get('api_key'),
|
||||
api_base=cfg.get('api_base'),
|
||||
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
|
||||
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
|
||||
debug=settings.debug,
|
||||
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
|
||||
)
|
||||
raw, tokens = _resp[0], _resp[1]
|
||||
input_tokens = _resp[2] if len(_resp) > 2 else 0
|
||||
|
||||
@@ -79,13 +79,7 @@ def build_eval_system_prompt() -> str:
|
||||
"Rules:\n"
|
||||
"- important=true ONLY when the diff clearly and specifically matches the intent — be strict\n"
|
||||
"- Pay close attention to direction: an intent about price drops means removed (-) prices and added (+) lower prices\n"
|
||||
"- The user's intent always wins. If the intent explicitly asks about timestamps, numbers, counters, "
|
||||
"thresholds, or any specific value (e.g. 'when the timestamp is greater than 1778599592', "
|
||||
"'when stock count > 5'), evaluate the diff against that intent — do NOT dismiss it as cosmetic.\n"
|
||||
"- Otherwise: empty, trivial, or genuinely cosmetic diffs (heartbeat timestamps, view counters, "
|
||||
"whitespace, navigation tweaks) default to important=false\n"
|
||||
"- For numeric comparisons in the intent, parse the values explicitly and compare them — "
|
||||
"do not eyeball or round\n"
|
||||
"- Empty, trivial, or cosmetic diffs (timestamps, counters, whitespace, navigation) → important=false\n"
|
||||
"- If the same text appears in both removed (-) and added (+) lines the content has likely just "
|
||||
"shifted or been reordered. Treat pure reordering as important=false unless the intent "
|
||||
"explicitly asks about order or position.\n"
|
||||
@@ -136,14 +130,7 @@ def build_change_summary_prompt(diff: str, custom_prompt: 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 (which carries its own surrounding context via unified_diff's
|
||||
n=3 context lines, marked '~' by _annotate_moved_lines).
|
||||
|
||||
NOTE: current_snapshot is accepted for caller compatibility but intentionally
|
||||
unused. A wholesale page excerpt caused the LLM to report unchanged page
|
||||
content (e.g. old release-note bullets) as "what changed" — hallucinations
|
||||
drawn from the excerpt rather than the diff. The in-diff context lines give
|
||||
the model enough surrounding text to describe each change accurately.
|
||||
with the diff and optional page context.
|
||||
"""
|
||||
parts = []
|
||||
if url:
|
||||
@@ -151,33 +138,42 @@ def build_change_summary_prompt(diff: str, custom_prompt: str,
|
||||
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{_annotate_moved_lines(diff)}")
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def build_change_summary_system_prompt() -> str:
|
||||
"""
|
||||
Universal, format-agnostic instructions: how to READ a diff and accuracy rules.
|
||||
All output-format choices (prose vs JSON, sections, bullets, language, length)
|
||||
are owned by the user prompt — including the default in
|
||||
DEFAULT_CHANGE_SUMMARY_PROMPT — so that a user replacing the user-prompt
|
||||
(e.g. asking for raw JSON) is not overridden by hard-coded format rules here.
|
||||
"""
|
||||
return (
|
||||
"You analyse a unified-diff document showing how a monitored web page changed, "
|
||||
"and produce exactly the output the user asks for.\n\n"
|
||||
"You are a meticulous, accurate summariser of website changes for monitoring notifications.\n"
|
||||
"Your goal is to describe exactly what changed — never omit significant details, "
|
||||
"never add information that isn't in the diff, and never speculate.\n\n"
|
||||
"Rules for reading the diff:\n"
|
||||
"- Lines starting with + are genuinely new content.\n"
|
||||
"- Lines starting with - are genuinely removed content.\n"
|
||||
"- Lines starting with + are genuinely new content. List them specifically.\n"
|
||||
"- Lines starting with - are genuinely removed content. List them specifically.\n"
|
||||
"- Lines starting with ~ have been PRE-IDENTIFIED as moved/reordered or trivial — "
|
||||
"the same text exists on both sides of the diff, or the line is a standalone timestamp. "
|
||||
"Do NOT treat ~ lines as added or removed.\n\n"
|
||||
"Accuracy: only report what the +/- lines actually contain. Never invent details, "
|
||||
"never speculate, never add information that isn't in the diff.\n\n"
|
||||
"Follow the user's instructions exactly — including the requested output format "
|
||||
"(plain text, JSON, Markdown, single value, etc.), structure, language, and length. "
|
||||
"Do not add preamble, meta-commentary, or self-introduction. Produce only the output "
|
||||
"the user asked for — nothing before it, nothing after it."
|
||||
"Do NOT report ~ lines as added or removed. "
|
||||
"If many ~ lines exist, note briefly that some content was reordered.\n"
|
||||
"- Never list standalone timestamps like '3 hours ago', 'Yesterday', '2 minutes ago' "
|
||||
"as added or removed items — they are not meaningful content changes.\n"
|
||||
"For content-heavy pages (news, listings, feeds): quote or paraphrase the specific new "
|
||||
"headlines, items, or entries that were added — do not collapse them into vague phrases "
|
||||
"like 'new articles were added' or 'section was expanded'.\n"
|
||||
"For large blocks of new text (full articles, documents, long paragraphs): briefly summarise "
|
||||
"the substance in 1-2 sentences capturing the key point — do not just repeat the title.\n\n"
|
||||
"Structure your response using these sections, in this fixed order — "
|
||||
"omit a section entirely if there is nothing to report for it:\n"
|
||||
" Added: ...\n"
|
||||
" Changed: ...\n"
|
||||
" Removed: ...\n"
|
||||
"The Removed section MUST always be last. Never place removals before additions or changes.\n\n"
|
||||
"Follow the user's formatting instructions exactly for structure, language, and length.\n"
|
||||
"Respond with ONLY the summary text — no JSON, no markdown code fences, no preamble. "
|
||||
"Just the description."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from os import getenv
|
||||
from copy import deepcopy
|
||||
|
||||
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_CONTENT_FORMAT_DEFAULT
|
||||
from changedetectionio.llm.evaluator import LLM_DEFAULT_MAX_SUMMARY_TOKENS, LLM_DEFAULT_THINKING_BUDGET
|
||||
from changedetectionio.model.Tags import TagsDict
|
||||
|
||||
from changedetectionio.notification import (
|
||||
@@ -70,9 +71,8 @@ class model(dict):
|
||||
'shared_diff_access': False,
|
||||
'strip_ignored_lines': False,
|
||||
'tags': None, # Initialized in __init__ with real datastore_path
|
||||
# All LLM settings now live nested under application.llm.* (post-migration update_31).
|
||||
# Defaults come from LLMSettings.model_validate({}) at read time —
|
||||
# no need to pre-seed an empty {} here.
|
||||
'llm_thinking_budget': LLM_DEFAULT_THINKING_BUDGET,
|
||||
'llm_max_summary_tokens': LLM_DEFAULT_MAX_SUMMARY_TOKENS,
|
||||
'webdriver_delay': None , # Extra delay in seconds before extracting text
|
||||
'ui': {
|
||||
'use_page_title_in_list': True,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""
|
||||
Validation/typing layer for the LLM config dict stored at
|
||||
datastore.data['settings']['application']['llm']
|
||||
|
||||
Storage stays a plain dict (orjson-serialized). This model is hydrated on read
|
||||
(model_validate) and dumped on write (model_dump). WTForms field names match
|
||||
the storage field names exactly — no aliases needed.
|
||||
"""
|
||||
from typing import ClassVar, Tuple
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
LLM_DEFAULT_THINKING_BUDGET = 0
|
||||
LLM_DEFAULT_MAX_SUMMARY_TOKENS = 3000
|
||||
LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER = 5
|
||||
LLM_DEFAULT_MAX_INPUT_CHARS = 100_000
|
||||
LLM_DEFAULT_BUDGET_ACTION = 'skip_llm'
|
||||
|
||||
|
||||
class LLMSettings(BaseModel):
|
||||
# extra='forbid' rejects any key that isn't a declared field with a
|
||||
# ValidationError. Loud failure forces new form fields to be declared here
|
||||
# before they can land in storage — closes the CWE-915 mass-assignment class
|
||||
# of bugs (see GHSA-h3x5-5j56-hm2j for the canonical example).
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
enabled: bool = True
|
||||
debug: bool = False
|
||||
override_diff_with_summary: bool = True
|
||||
restock_use_fallback_extract: bool = True
|
||||
thinking_budget: int = LLM_DEFAULT_THINKING_BUDGET
|
||||
max_summary_tokens: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS
|
||||
budget_action: str = LLM_DEFAULT_BUDGET_ACTION
|
||||
change_summary_default: str = ''
|
||||
token_budget_month: int = 0
|
||||
max_input_chars: int = LLM_DEFAULT_MAX_INPUT_CHARS
|
||||
# Per-watch per-period token cap; read by _check_token_budget() in evaluator.py.
|
||||
# 0 means unlimited. Once a watch's usage within the current period hits this cap,
|
||||
# AI evaluation is skipped for it until the period rolls over. Period is currently
|
||||
# hard-coded to month (matches the global counter rollover); name is period-agnostic
|
||||
# to leave room for a configurable period (day/week/month) later.
|
||||
max_tokens_per_count_period: int = 0
|
||||
|
||||
model: str = ''
|
||||
api_key: str = ''
|
||||
api_base: str = ''
|
||||
provider_kind: str = ''
|
||||
local_token_multiplier: int = LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER
|
||||
|
||||
tokens_total_cumulative: int = 0
|
||||
tokens_this_month: int = 0
|
||||
tokens_month_key: str = ''
|
||||
cost_usd_total_cumulative: float = 0.0
|
||||
cost_usd_this_month: float = 0.0
|
||||
|
||||
# Provider-connection fields wiped on /llm/clear and when the model is emptied.
|
||||
CONNECTION_FIELDS: ClassVar[Tuple[str, ...]] = (
|
||||
'model', 'api_key', 'api_base', 'provider_kind', 'local_token_multiplier',
|
||||
)
|
||||
# Runtime-managed counters — form submissions must never overwrite these.
|
||||
PROTECTED_FIELDS: ClassVar[Tuple[str, ...]] = (
|
||||
'tokens_total_cumulative', 'tokens_this_month', 'tokens_month_key',
|
||||
'cost_usd_total_cumulative', 'cost_usd_this_month',
|
||||
)
|
||||
@@ -1,239 +0,0 @@
|
||||
# Pydantic Migration
|
||||
|
||||
Plan for incrementally moving the app's storage dicts behind Pydantic models. Driven by
|
||||
security (CWE-915 mass-assignment, see [GHSA-h3x5-5j56-hm2j][advisory]) and schema
|
||||
enforcement, not just type tidying.
|
||||
|
||||
[advisory]: https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-h3x5-5j56-hm2j
|
||||
|
||||
## The goal
|
||||
|
||||
Every form/API endpoint that mutates a stored dict should validate input against a
|
||||
declared schema before writing. `extra='forbid'` rejects unknown keys — so an attacker
|
||||
POSTing extra fields like `uuid=…`, `last_checked=…`, `history=[…]` can't smuggle them
|
||||
into storage. Per-route allowlists work but rot; one declared schema per stored shape
|
||||
doesn't.
|
||||
|
||||
## Prefer a migration over permanent complexity
|
||||
|
||||
If you're about to add a compatibility shim, an alias, a backward-compat fallback, or a
|
||||
"handle both old and new shape" branch — stop and ask whether a one-time `update_N`
|
||||
migration solves the same problem by *renaming the stored data*. A migration runs once
|
||||
per install; the shim lives in the code forever and every future contributor has to
|
||||
understand it.
|
||||
|
||||
Concrete example from this PR: the original design used `Field(alias='llm_X')` so
|
||||
Pydantic could accept both the legacy form-field name (`llm_model`) and the new
|
||||
storage name (`model`). That alias survived every read/write for the life of the app
|
||||
and introduced a subtle `model_dump(by_alias=True)` merge bug. The simpler answer was
|
||||
to rename the form fields to match the storage names (an in-PR rename, no migration
|
||||
needed since storage was new), drop the aliases entirely, and delete ~25 lines of
|
||||
plumbing. **Pay once with a migration; don't pay forever with complexity.**
|
||||
|
||||
Same principle applies the moment you find yourself writing `dict.get(new_key) or
|
||||
dict.get(old_key)`. That's a migration in disguise — write the migration instead.
|
||||
|
||||
## Architecture choice: validator at the boundary, not domain model
|
||||
|
||||
There are two ways to use Pydantic. Pick one per slice — they are not interchangeable.
|
||||
|
||||
**Pydantic-as-validator (what we do).** Storage stays a plain dict. A `BaseModel`
|
||||
validates input at the boundary, dumps back to a dict. No call-site changes; the
|
||||
existing `watch['x']` dict access keeps working everywhere.
|
||||
|
||||
**Pydantic-as-domain-model.** Replace `dict` inheritance with `BaseModel`. ~190 call
|
||||
sites switch from `watch['x']` to `watch.x`. Much bigger blast radius, defers the
|
||||
security win. Not what we're doing right now.
|
||||
|
||||
The CWE-915 fix only needs the validator pattern. Domain-model replacement is a
|
||||
separate, later project.
|
||||
|
||||
## The template (LLMSettings)
|
||||
|
||||
The first migrated slice. Use as the reference for the next one.
|
||||
|
||||
**Match the WTForms field names to the storage / Pydantic field names** so the
|
||||
form-input dict and the storage dict have the same key shape. No aliases, no
|
||||
`populate_by_name=True`, no `by_alias=True` merge gymnastics. Only reach for
|
||||
`Field(alias=…)` if you genuinely cannot rename the form field (rare).
|
||||
|
||||
`model/LLMSettings.py`:
|
||||
|
||||
```python
|
||||
class LLMSettings(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
enabled: bool = True
|
||||
model: str = ''
|
||||
...
|
||||
|
||||
# System-managed counters
|
||||
tokens_total_cumulative: int = 0
|
||||
...
|
||||
|
||||
# Field groups
|
||||
CONNECTION_FIELDS: ClassVar[Tuple[str, ...]] = ('model', 'api_key', ...)
|
||||
PROTECTED_FIELDS: ClassVar[Tuple[str, ...]] = ('tokens_total_cumulative', ...)
|
||||
```
|
||||
|
||||
Boundary pattern at the route handler:
|
||||
|
||||
```python
|
||||
# Read
|
||||
settings = LLMSettings.model_validate(
|
||||
datastore.data['settings']['application'].get('llm') or {}
|
||||
)
|
||||
|
||||
# Merge form input
|
||||
form_input = dict(form.data.get('llm') or {})
|
||||
for protected in LLMSettings.PROTECTED_FIELDS:
|
||||
form_input.pop(protected, None) # counters never come from form
|
||||
merged = LLMSettings.model_validate({**settings.model_dump(), **form_input})
|
||||
|
||||
# Write — re-validates the schema on every write
|
||||
datastore.data['settings']['application']['llm'] = merged.model_dump()
|
||||
```
|
||||
|
||||
## Unresolved architectural decisions
|
||||
|
||||
Two decisions need answers before the `WatchInput` slice. They're not blockers for `App.py`.
|
||||
|
||||
### OpenAPI spec vs Pydantic model — who's source of truth?
|
||||
|
||||
Today: `docs/api-spec.yaml` declares the Watch/Tag shape; `model/schema_utils.py` reads
|
||||
it to compute readonly fields; the API layer validates against it; the model layer is a
|
||||
plain dict that doesn't know about either. When `WatchInput` lands, that's a third
|
||||
shape declaration.
|
||||
|
||||
Two ways to live:
|
||||
- **Pydantic is source.** Generate / sync `api-spec.yaml` from the model
|
||||
(e.g. via `model_json_schema()`). One declaration, multiple consumers. Long-term
|
||||
right answer; needs tooling.
|
||||
- **Parallel sources with discipline.** Hand-keep them aligned. Faster to ship but
|
||||
drift is inevitable — that's the bug class we're already trying to close.
|
||||
|
||||
Recommendation: start parallel (keep `api-spec.yaml` for now), but write Watch's
|
||||
Pydantic model so it could be the eventual single source. Don't *invent* a new
|
||||
field shape — match the spec.
|
||||
|
||||
### Plugin / processor_config_* extensibility
|
||||
|
||||
`processor_config_restock_diff` (and future processor configs) are written by
|
||||
plugins, not the core. `extra='forbid'` on a Watch input model would reject them.
|
||||
|
||||
Options:
|
||||
- **Per-processor sub-models.** Each plugin owns its `<Processor>Settings` Pydantic
|
||||
model; Watch input validates only core fields, processor configs validate
|
||||
separately at their own boundary (the per-watch `restock_diff.json`, etc.).
|
||||
- **Opaque pass-through.** Watch input model treats `processor_config_*` as a
|
||||
declared dict-typed field. Loses per-key validation but preserves the
|
||||
plugin-extensibility contract.
|
||||
|
||||
Recommendation: per-processor sub-models. Matches the file split already done in
|
||||
`update_30` (separate `restock_diff.json` per watch).
|
||||
|
||||
## Migration order
|
||||
|
||||
| Target | Difficulty | Value | Status |
|
||||
|---|---|---|---|
|
||||
| `LLMSettings` | low | medium | done (this PR) |
|
||||
| `App.py` → `AppSettings` (nested) | low | medium | next |
|
||||
| `WatchInput` (form/API validator) | medium | **HIGH — closes [GHSA-h3x5-5j56-hm2j][advisory]** | next-next |
|
||||
| `TagInput` (form/API validator) | medium | medium | after Watch |
|
||||
| `watch_base(dict)` → `BaseModel` | very high | high | separate multi-PR project, much later |
|
||||
|
||||
`Tags.py` (TagsDict), `persistence.py`, `schema_utils.py` are not data models — leave alone.
|
||||
|
||||
### Concrete next steps
|
||||
|
||||
1. **`App.py`.** Pure dict tree under `settings.{application,requests,headers}`. Define
|
||||
nested `BaseModel`s; `LLMSettings` slots in as the existing sub-tree. No call-site
|
||||
churn — just the global settings POST handler. Sets the pattern for nested models.
|
||||
|
||||
2. **`WatchInput` BaseModel** for `blueprint/ui/edit.py:225` and `api/Watch.py`. Replace:
|
||||
```python
|
||||
datastore.data['watching'][uuid].update(form.data) # CWE-915
|
||||
```
|
||||
with:
|
||||
```python
|
||||
validated = WatchInput.model_validate(form.data)
|
||||
datastore.data['watching'][uuid].update(validated.model_dump())
|
||||
```
|
||||
Closes the unpatched advisory. Should be a security-tagged commit referencing the GHSA.
|
||||
|
||||
3. **`TagInput` BaseModel** — same pattern, smaller.
|
||||
|
||||
## Gotchas discovered
|
||||
|
||||
These cost real debugging time in the LLMSettings PR. Worth knowing before the next slice.
|
||||
|
||||
### `extra='forbid'` is the right default
|
||||
|
||||
`extra='ignore'` silently drops unknowns and hides developer mistakes (add a form field,
|
||||
forget to declare it on the model, your feature appears to work until you reload). `forbid`
|
||||
fails loudly. `allow` defeats the purpose entirely — it's how injection succeeds.
|
||||
|
||||
### Don't use Field aliases unless you actually need them
|
||||
|
||||
The LLMSettings PR originally used `alias='llm_X'` to bridge llm_-prefixed WTForms
|
||||
names to stripped storage names. That created a documented gotcha: with
|
||||
`extra='forbid'`, having both `model` and `llm_model` in the same input dict is a
|
||||
`ValidationError`, and merging existing-storage-dump with form input required
|
||||
`by_alias=True` to keep both sides on the alias shape. We fixed it by renaming the
|
||||
form fields to match the storage field names. **Match the form to the model
|
||||
upfront and you avoid the whole class of merge bugs.**
|
||||
|
||||
### Round-trip counters through the model, don't mutate the dict
|
||||
|
||||
If runtime code (e.g. a token accumulator) writes to the storage dict directly, the
|
||||
schema is bypassed. Load → mutate instance attributes → `model_dump()` → write back.
|
||||
This re-validates on every write and prevents drift.
|
||||
|
||||
### Per-call validation needs strict + tolerant modes? Don't.
|
||||
|
||||
You might be tempted to validate form input strictly but allow extras in storage
|
||||
hydration. Don't — `extra='forbid'` everywhere means storage drift is impossible. If
|
||||
something put unknown keys in storage, you want loud failure, not silent acceptance.
|
||||
|
||||
### Migrations are convention-based by accident if you let them be
|
||||
|
||||
`for k in list(d) if k.startswith('llm_')` is shorter than an explicit list but
|
||||
silently catches any future flat `llm_*` key. Migrations are forever — prefer an
|
||||
explicit allowlist of keys to move, even if it's verbose.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Don't add custom helper methods (`dump_without_connection()`, `clear_X()`) when stock
|
||||
`model_dump(exclude=set(FIELDS))` works. The standard idiom is more readable and
|
||||
zero-line.
|
||||
- Don't push security/business logic into the model (e.g. SSRF guards, credential-exfil
|
||||
checks). The model owns field shape and validation. Route handlers own
|
||||
policy. Mixing them dilutes both.
|
||||
- Don't make `get_X_config()` return a Pydantic instance if callers do dict-style access.
|
||||
Either migrate all call sites (high-touch) or keep returning a dict and let the model
|
||||
be the validation/dump layer only.
|
||||
- Don't `model_copy(update=...)` without re-validating. It doesn't coerce types or
|
||||
enforce `extra='forbid'`. Use `model_validate({**old.model_dump(), **updates})` for
|
||||
strict merges.
|
||||
|
||||
## Required for each new slice
|
||||
|
||||
Each migration PR should ship:
|
||||
|
||||
- `model/<Thing>Settings.py` (or input model) — declared schema, `extra='forbid'`,
|
||||
field aliases if there's a name mismatch between form and storage.
|
||||
- `store/updates.py:update_N` if the storage shape changes. Pure dict-shuffling, no
|
||||
Pydantic import (migrations should not depend on the model — model evolves
|
||||
independently).
|
||||
- `tests/unit/test_<thing>.py` — unit coverage of the model itself: defaults,
|
||||
alias merge, type coercion, `extra='forbid'` rejection, dump shapes.
|
||||
- All runtime callers updated to go through `get_<thing>_settings(datastore)` or
|
||||
equivalent, not raw dict reads.
|
||||
|
||||
## Reference
|
||||
|
||||
- `model/LLMSettings.py` — the template
|
||||
- `tests/unit/test_llm_settings.py` — model unit-test template
|
||||
- `store/updates.py:update_31` — schema migration template
|
||||
- `blueprint/settings/__init__.py` (POST handler) — boundary-validation template
|
||||
- `llm/evaluator.py:accumulate_global_tokens` — instance-mutation-then-dump-back template
|
||||
@@ -1024,10 +1024,8 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
prompt_hash = self._llm_summary_prompt_hash(prompt)
|
||||
fname = os.path.join(self.data_dir, f'change-summary-{from_version}-to-{to_version}-{prompt_hash}.txt')
|
||||
if not os.path.isfile(fname):
|
||||
logger.debug(f"LLM cached diff summary '{fname}' NOT found")
|
||||
return ''
|
||||
with open(fname, 'r', encoding='utf-8') as f:
|
||||
logger.debug(f"LLM cached diff summary '{fname}' FOUND")
|
||||
return f.read().strip()
|
||||
|
||||
def save_llm_diff_summary(self, summary: str, from_version, to_version, prompt: str = ''):
|
||||
@@ -1066,7 +1064,6 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
Prepare watch data for commit.
|
||||
|
||||
Excludes processor_config_* keys (stored in separate files).
|
||||
Excludes __-prefixed keys (transient in-memory state — must not persist to disk).
|
||||
Normalizes browser_steps to empty list if no meaningful steps.
|
||||
"""
|
||||
import copy
|
||||
@@ -1080,11 +1077,8 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
else:
|
||||
snapshot = dict(self)
|
||||
|
||||
# Exclude processor config keys (stored separately) and __-prefixed transient keys
|
||||
watch_dict = {
|
||||
k: copy.deepcopy(v) for k, v in snapshot.items()
|
||||
if not k.startswith('processor_config_') and not k.startswith('__')
|
||||
}
|
||||
# Exclude processor config keys (stored separately)
|
||||
watch_dict = {k: copy.deepcopy(v) for k, v in snapshot.items() if not k.startswith('processor_config_')}
|
||||
|
||||
# Normalize browser_steps: if no meaningful steps, save as empty list
|
||||
if not self.has_browser_steps:
|
||||
|
||||
@@ -335,22 +335,29 @@ class watch_base(dict):
|
||||
if self.__watch_was_edited:
|
||||
return # Already marked as edited
|
||||
|
||||
# __-prefixed keys are transient in-memory state (e.g. __check_status set by
|
||||
# set_watch_minitext_status). They never persist to disk and must not trigger
|
||||
# the edited flag — otherwise just observing a check in progress would force
|
||||
# the next run to bypass the unchanged-content skip.
|
||||
if isinstance(key, str) and key.startswith('__'):
|
||||
return
|
||||
|
||||
# Import from shared schema utilities (no circular dependency)
|
||||
from .schema_utils import get_readonly_watch_fields, SYSTEM_MANAGED_NON_SPEC_FIELDS
|
||||
from .schema_utils import get_readonly_watch_fields
|
||||
readonly_fields = get_readonly_watch_fields()
|
||||
|
||||
# `last_viewed` is set internally by mark_all_viewed and shouldn't flag the watch as
|
||||
# edited, but is not in SYSTEM_MANAGED_NON_SPEC_FIELDS because it IS user-writable via
|
||||
# the UpdateWatch schema (the API path).
|
||||
if (key not in get_readonly_watch_fields()
|
||||
and key != 'last_viewed'
|
||||
and key not in SYSTEM_MANAGED_NON_SPEC_FIELDS):
|
||||
# Additional system-managed fields not in OpenAPI spec (yet)
|
||||
# These are set by processors/workers and should not trigger edited flag
|
||||
additional_system_fields = {
|
||||
'last_check_status', # Set by processors
|
||||
'last_filter_config_hash', # Set by text_json_diff processor, internal skip-cache
|
||||
'restock', # Set by restock processor
|
||||
'last_viewed', # Set by mark_all_viewed endpoint
|
||||
# LLM runtime fields written back by worker/evaluator
|
||||
'_llm_result',
|
||||
'_llm_intent',
|
||||
'_llm_change_summary',
|
||||
'llm_prefilter',
|
||||
'llm_evaluation_cache',
|
||||
'llm_last_tokens_used',
|
||||
'llm_tokens_used_cumulative',
|
||||
}
|
||||
|
||||
# Only mark as edited if this is a user-writable field
|
||||
if key not in readonly_fields and key not in additional_system_fields:
|
||||
self.__watch_was_edited = True
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
|
||||
@@ -8,35 +8,6 @@ Shared by both the model layer and API layer to avoid circular dependencies.
|
||||
import functools
|
||||
|
||||
|
||||
# Watch fields written by workers/processors that are NOT part of the public OpenAPI spec.
|
||||
#
|
||||
# These fields exist on a watch dict at runtime but are internal implementation details
|
||||
# (skip-cache hashes, last-check status strings, LLM runtime state, etc.). Used by:
|
||||
# - model/__init__.py: don't trigger the "edited" flag when these are written internally
|
||||
# - api/Watch.py: strip from GET responses and silently discard from PUT/POST inputs
|
||||
# so that a GET → PUT round trip doesn't trip the unknown-field validator
|
||||
#
|
||||
# `last_viewed` is intentionally NOT included: it's set internally by mark_all_viewed BUT
|
||||
# is also explicitly writable via the UpdateWatch schema (see api/Watch.py valid_fields).
|
||||
SYSTEM_MANAGED_NON_SPEC_FIELDS = frozenset({
|
||||
'last_check_status', # Set by processors
|
||||
'last_filter_config_hash', # text_json_diff internal skip-cache
|
||||
'restock', # Set by restock processor
|
||||
'_llm_result', # LLM runtime — populated by evaluator
|
||||
'_llm_intent',
|
||||
'_llm_change_summary',
|
||||
'llm_prefilter',
|
||||
'llm_evaluation_cache',
|
||||
'llm_last_tokens_used',
|
||||
'llm_tokens_used_cumulative',
|
||||
})
|
||||
|
||||
|
||||
def get_system_managed_non_spec_fields():
|
||||
"""Return the set of internal fields not in the public OpenAPI spec."""
|
||||
return SYSTEM_MANAGED_NON_SPEC_FIELDS
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_openapi_schema_dict():
|
||||
"""
|
||||
|
||||
@@ -60,7 +60,7 @@ 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, is_url_private_or_parser_confused
|
||||
from changedetectionio.validate_url import is_private_hostname
|
||||
|
||||
SUPPORTED_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head"}
|
||||
|
||||
@@ -198,14 +198,12 @@ 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.
|
||||
# Uses parser-agnostic check so urlparse/urllib3 differentials (GHSA-rph4-96w6-q594)
|
||||
# can't smuggle an internal target past the gate.
|
||||
# SSRF protection — block private/loopback addresses unless explicitly allowed
|
||||
if not os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', '').lower() in ('true', '1', 'yes'):
|
||||
if is_url_private_or_parser_confused(url):
|
||||
hostname = urlparse(url).hostname or ''
|
||||
if hostname and is_private_hostname(hostname):
|
||||
raise ValueError(
|
||||
f"Notification target '{url}' is a private/reserved address "
|
||||
f"or contains a parser-differential payload. "
|
||||
f"Notification target '{hostname}' is a private/reserved address. "
|
||||
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
|
||||
)
|
||||
|
||||
|
||||
@@ -65,9 +65,6 @@ def notification_format_align_with_apprise(n_format : str):
|
||||
:return:
|
||||
"""
|
||||
|
||||
if not n_format:
|
||||
return NotifyFormat.TEXT.value
|
||||
|
||||
if n_format.startswith('html'):
|
||||
# Apprise only knows 'html' not 'htmlcolor' etc, which shouldnt matter here
|
||||
n_format = NotifyFormat.HTML.value
|
||||
@@ -364,10 +361,6 @@ def process_notification(n_object: NotificationContextData, datastore):
|
||||
# Should always be false for 'text' mode or its too hard to read
|
||||
# But otherwise, this could be some setting
|
||||
word_diff=False if requested_output_format_original == 'text' else True,
|
||||
# HTML-format notifications must escape diff content (GHSA-q8xq-qg4x-wphg).
|
||||
# FormattableDiff/Extract escape internally so {{ diff(...) }} stays callable —
|
||||
# the post-Jinja escape loop below would otherwise convert them to plain str.
|
||||
escape_output='html' in requested_output_format,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -376,8 +369,7 @@ def process_notification(n_object: NotificationContextData, datastore):
|
||||
|
||||
# AI Change Summary: optionally replace {{ diff }} with the AI summary
|
||||
_llm_change_summary = (n_object.get('_llm_change_summary') or '').strip()
|
||||
from changedetectionio.llm.evaluator import get_llm_settings
|
||||
_override_diff = get_llm_settings(datastore).override_diff_with_summary
|
||||
_override_diff = datastore.data['settings']['application'].get('llm_override_diff_with_summary', True)
|
||||
if _llm_change_summary and _override_diff:
|
||||
n_object['diff'] = _llm_change_summary
|
||||
|
||||
@@ -387,32 +379,6 @@ def process_notification(n_object: NotificationContextData, datastore):
|
||||
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', '')
|
||||
|
||||
# Escape diff/snapshot variables before Jinja renders them into an HTML notification.
|
||||
# GHSA-q8xq-qg4x-wphg: inscriptis decodes HTML entities when converting text/html
|
||||
# pages to snapshot text, so a page that visibly displays "<a href...>" yields
|
||||
# literal "<a href...>" in the snapshot — which would otherwise render as live
|
||||
# markup in HTML emails / Telegram (parse_mode=html) / Discord embeds, letting a
|
||||
# watched page inject phishing links into the operator's notification channel.
|
||||
# Also covers #3529 — raw '<' chars from text/plain pages breaking HTML email layout.
|
||||
# The operator's own template HTML (e.g. <a href="{{watch_url}}">) is outside the
|
||||
# variable values so it stays untouched. Diff placemarkers contain no HTML chars,
|
||||
# so they survive escape and are still replaced with <span> tags later.
|
||||
if 'html' in requested_output_format:
|
||||
from markupsafe import escape as html_escape
|
||||
from changedetectionio.notification_service import FormattableDiff, FormattableExtract
|
||||
_page_content_keys = {'raw_diff', 'current_snapshot', 'prev_snapshot', 'triggered_text'}
|
||||
for key in [k for k in notification_parameters if k.startswith('diff') or k in _page_content_keys]:
|
||||
value = notification_parameters.get(key)
|
||||
if not value:
|
||||
continue
|
||||
# FormattableDiff / FormattableExtract are callable str subclasses — {{ diff(lines=5) }}
|
||||
# etc. relies on __call__. Wrapping them with str(html_escape(...)) here would lose
|
||||
# __call__ and break those tokens. They escape internally via escape_output=True
|
||||
# (set by add_rendered_diff_to_notification_vars above) for both __str__ and __call__.
|
||||
if isinstance(value, (FormattableDiff, FormattableExtract)):
|
||||
continue
|
||||
notification_parameters[key] = str(html_escape(str(value)))
|
||||
|
||||
with (apprise.LogCapture(level=apprise.logging.DEBUG) as logs):
|
||||
for url in n_object['notification_urls']:
|
||||
|
||||
@@ -430,6 +396,13 @@ def process_notification(n_object: NotificationContextData, datastore):
|
||||
logger.info(f">> Process Notification: AppRise start notifying '{url}'")
|
||||
url = jinja_render(template_str=url, **notification_parameters)
|
||||
|
||||
# If it's a plaintext document, and they want HTML type email/alerts, so it needs to be escaped
|
||||
watch_mime_type = n_object.get('watch_mime_type')
|
||||
if watch_mime_type and 'text/' in watch_mime_type.lower() and not 'html' in watch_mime_type.lower():
|
||||
if 'html' in requested_output_format:
|
||||
from markupsafe import escape
|
||||
n_body = str(escape(n_body))
|
||||
|
||||
if 'html' in requested_output_format:
|
||||
# Since the n_body is always some kind of text from the 'diff' engine, attempt to preserve whitespaces that get sent to the HTML output
|
||||
# But only where its more than 1 consecutive whitespace, otherwise "and this" becomes "and this" etc which is too much.
|
||||
|
||||
@@ -29,7 +29,7 @@ def _check_cascading_vars(datastore, var_name, watch):
|
||||
v = watch.get(var_name)
|
||||
if v and not watch.get('notification_muted'):
|
||||
if var_name == 'notification_format' and v == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:
|
||||
return datastore.data['settings']['application'].get('notification_format') or default_notification_format
|
||||
return datastore.data['settings']['application'].get('notification_format')
|
||||
|
||||
return v
|
||||
|
||||
@@ -99,7 +99,7 @@ class FormattableExtract(str):
|
||||
Multiple changed fragments are joined with newlines.
|
||||
Being a str subclass means it is natively JSON serializable.
|
||||
"""
|
||||
def __new__(cls, prev_snapshot, current_snapshot, extract_fn, escape_output=False):
|
||||
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
|
||||
if prev_snapshot or current_snapshot:
|
||||
from changedetectionio import diff as diff_module
|
||||
# word_diff=True is required — placemarker extraction regexes only exist in word-diff output
|
||||
@@ -107,12 +107,6 @@ class FormattableExtract(str):
|
||||
extracted = extract_fn(raw)
|
||||
else:
|
||||
extracted = ''
|
||||
if escape_output and extracted:
|
||||
# Placemarkers (@removed_PLACEMARKER_OPEN etc) contain no HTML chars,
|
||||
# so html_escape leaves them intact — they still get swapped to <span>
|
||||
# tags later by apply_service_tweaks. See GHSA-q8xq-qg4x-wphg.
|
||||
from markupsafe import escape as html_escape
|
||||
extracted = str(html_escape(extracted))
|
||||
instance = super().__new__(cls, extracted)
|
||||
return instance
|
||||
|
||||
@@ -134,23 +128,16 @@ class FormattableDiff(str):
|
||||
|
||||
Being a str subclass means it is natively JSON serializable.
|
||||
"""
|
||||
def __new__(cls, prev_snapshot, current_snapshot, escape_output=False, **base_kwargs):
|
||||
def __new__(cls, prev_snapshot, current_snapshot, **base_kwargs):
|
||||
if prev_snapshot or current_snapshot:
|
||||
from changedetectionio import diff as diff_module
|
||||
rendered = diff_module.render_diff(prev_snapshot, current_snapshot, **base_kwargs)
|
||||
else:
|
||||
rendered = ''
|
||||
if escape_output and rendered:
|
||||
# Placemarkers (@removed_PLACEMARKER_OPEN etc) contain no HTML chars,
|
||||
# so html_escape leaves them intact — they still get swapped to <span>
|
||||
# tags later by apply_service_tweaks. See GHSA-q8xq-qg4x-wphg.
|
||||
from markupsafe import escape as html_escape
|
||||
rendered = str(html_escape(rendered))
|
||||
instance = super().__new__(cls, rendered)
|
||||
instance._prev = prev_snapshot
|
||||
instance._current = current_snapshot
|
||||
instance._base_kwargs = base_kwargs
|
||||
instance._escape_output = escape_output
|
||||
return instance
|
||||
|
||||
def __call__(self, lines=None, added_only=False, removed_only=False, context=0,
|
||||
@@ -176,10 +163,6 @@ class FormattableDiff(str):
|
||||
if lines is not None:
|
||||
result = '\n'.join(result.splitlines()[:int(lines)])
|
||||
|
||||
if self._escape_output and result:
|
||||
from markupsafe import escape as html_escape
|
||||
result = str(html_escape(result))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -204,8 +187,6 @@ class NotificationContextData(dict):
|
||||
'diff_changed_from': FormattableExtract('', '', extract_fn=lambda x: x),
|
||||
'diff_changed_to': FormattableExtract('', '', extract_fn=lambda x: x),
|
||||
'diff_url': None,
|
||||
# Always the raw +/- diff regardless of LLM summary override (populated in handler.py from {{diff}})
|
||||
'raw_diff': FormattableDiff('', ''),
|
||||
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
|
||||
'notification_timestamp': time.time(),
|
||||
'prev_snapshot': None,
|
||||
@@ -255,7 +236,7 @@ class NotificationContextData(dict):
|
||||
|
||||
super().__setitem__(key, value)
|
||||
|
||||
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool, escape_output:bool=False):
|
||||
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool):
|
||||
"""
|
||||
Efficiently renders only the diff placeholders that are actually used in the notification text.
|
||||
|
||||
@@ -268,9 +249,6 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
|
||||
prev_snapshot: Previous version of content for diff comparison
|
||||
current_snapshot: Current version of content for diff comparison
|
||||
word_diff: Whether to use word-level (True) or line-level (False) diffing
|
||||
escape_output: If True, the rendered diff output is HTML-escaped. Used for HTML-format
|
||||
notifications so attacker-controlled page content can't inject live markup.
|
||||
Both the cached str representation and the result of {{ diff(...) }} calls are escaped.
|
||||
|
||||
Returns:
|
||||
dict: Only the diff placeholders that were found in notification_scan_text, with rendered content
|
||||
@@ -309,10 +287,10 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
|
||||
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
|
||||
continue
|
||||
if key in diff_specs:
|
||||
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, escape_output=escape_output, **diff_specs[key])
|
||||
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
|
||||
rendered_count += 1
|
||||
elif key in extract_specs:
|
||||
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key], escape_output=escape_output)
|
||||
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
|
||||
rendered_count += 1
|
||||
|
||||
if rendered_count:
|
||||
@@ -479,7 +457,7 @@ Thanks - Your omniscient changedetection.io installation.
|
||||
'notification_body': body,
|
||||
'notification_format': _check_cascading_vars(self.datastore, 'notification_format', watch),
|
||||
})
|
||||
n_object['markup_text_links_to_html_links'] = (n_object.get('notification_format') or '').startswith('html')
|
||||
n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html')
|
||||
|
||||
if len(watch['notification_urls']):
|
||||
n_object['notification_urls'] = watch['notification_urls']
|
||||
@@ -528,7 +506,7 @@ Thanks - Your omniscient changedetection.io installation.
|
||||
'notification_body': body,
|
||||
'notification_format': _check_cascading_vars(self.datastore, 'notification_format', watch),
|
||||
})
|
||||
n_object['markup_text_links_to_html_links'] = (n_object.get('notification_format') or '').startswith('html')
|
||||
n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html')
|
||||
|
||||
if len(watch['notification_urls']):
|
||||
n_object['notification_urls'] = watch['notification_urls']
|
||||
|
||||
@@ -5,7 +5,7 @@ import hashlib
|
||||
from changedetectionio.browser_steps.browser_steps import browser_steps_get_valid_steps
|
||||
from changedetectionio.content_fetchers.base import Fetcher
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
|
||||
from changedetectionio.validate_url import is_private_hostname
|
||||
from copy import deepcopy
|
||||
from abc import abstractmethod
|
||||
import os
|
||||
@@ -104,13 +104,13 @@ class difference_detection_processor():
|
||||
"""
|
||||
if strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
|
||||
return
|
||||
parsed = urlparse(self.watch.link)
|
||||
if not parsed.hostname:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
# Use the parser-agnostic check so urlparse/urllib3 differentials (GHSA-rph4-96w6-q594)
|
||||
# can't slip a private/internal hostname past this pre-flight gate.
|
||||
if await loop.run_in_executor(None, is_url_private_or_parser_confused, self.watch.link):
|
||||
if await loop.run_in_executor(None, is_private_hostname, parsed.hostname):
|
||||
raise Exception(
|
||||
f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address "
|
||||
f"or contains a parser-differential payload. "
|
||||
f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address. "
|
||||
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
|
||||
)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class Restock(dict):
|
||||
'in_stock': None,
|
||||
'price': None,
|
||||
'currency': None,
|
||||
'last_price': None # Price recorded at the most recent check (was misleadingly named 'original_price')
|
||||
'original_price': None
|
||||
}
|
||||
|
||||
# Initialize the dictionary with default values
|
||||
@@ -59,8 +59,8 @@ class Restock(dict):
|
||||
raise ValueError("Only one positional argument of type 'dict' is allowed")
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
# Custom logic to handle setting price and last_price
|
||||
if key == 'price' or key == 'last_price':
|
||||
# Custom logic to handle setting price and original_price
|
||||
if key == 'price' or key == 'original_price':
|
||||
if isinstance(value, str):
|
||||
value = self.parse_currency(raw_value=value)
|
||||
|
||||
@@ -89,8 +89,7 @@ class Watch(BaseWatch):
|
||||
|
||||
def extra_notification_token_values(self):
|
||||
values = super().extra_notification_token_values()
|
||||
# Copy so the derived 'previous_price' token added below doesn't mutate the stored restock object
|
||||
values['restock'] = dict(self.get('restock', {}))
|
||||
values['restock'] = self.get('restock', {})
|
||||
|
||||
values['restock']['previous_price'] = None
|
||||
if self.history_n >= 2:
|
||||
@@ -110,7 +109,7 @@ class Watch(BaseWatch):
|
||||
|
||||
values.append(('restock.price', "Price detected"))
|
||||
values.append(('restock.in_stock', "In stock status"))
|
||||
values.append(('restock.last_price', "Price at the previous check"))
|
||||
values.append(('restock.original_price', "Original price at first check"))
|
||||
values.append(('restock.previous_price', "Previous price in history"))
|
||||
|
||||
return values
|
||||
|
||||
@@ -22,7 +22,7 @@ class RestockSettingsForm(Form):
|
||||
render_kw={"placeholder": _l("No limit"), "size": "10"})
|
||||
price_change_max = FloatField(_l('Above price to trigger notification'), [validators.Optional()],
|
||||
render_kw={"placeholder": _l("No limit"), "size": "10"})
|
||||
price_change_threshold_percent = FloatField(_l('Threshold (%) for price changes since the previous check'), validators=[
|
||||
price_change_threshold_percent = FloatField(_l('Threshold in %% for price changes since the original price'), validators=[
|
||||
|
||||
validators.Optional(),
|
||||
validators.NumberRange(min=0, max=100, message=_l("Should be between 0 and 100")),
|
||||
@@ -73,8 +73,8 @@ class processor_settings_form(processor_text_json_diff_form):
|
||||
</fieldset>
|
||||
<fieldset class="pure-group price-change-minmax">
|
||||
{{ render_field(form.processor_config_restock_diff.price_change_threshold_percent) }}
|
||||
<span class="pure-form-message-inline">Price must change more than this % since the previous check to trigger a change.</span><br>
|
||||
<span class="pure-form-message-inline">For example, if the previous check saw the product at $1,000 USD, <strong>2%</strong> would mean it has to change more than $20 since then.</span><br>
|
||||
<span class="pure-form-message-inline">Price must change more than this % to trigger a change since the first check.</span><br>
|
||||
<span class="pure-form-message-inline">For example, If the product is $1,000 USD originally, <strong>2%</strong> would mean it has to change more than $20 since the first check.</span><br>
|
||||
</fieldset>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -13,7 +13,6 @@ import json
|
||||
import re
|
||||
from loguru import logger
|
||||
from changedetectionio.pluggy_interface import hookimpl
|
||||
from changedetectionio.llm.evaluator import apply_local_token_multiplier
|
||||
|
||||
# Injected at startup by inject_datastore_into_plugins()
|
||||
datastore = None
|
||||
@@ -196,23 +195,22 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
|
||||
logger.debug("LLM restock fallback: no datastore injected yet, skipping")
|
||||
return None
|
||||
|
||||
# Gate on the user setting (default True — enabled out of the box)
|
||||
app_settings = datastore.data.get('settings', {}).get('application', {})
|
||||
if not app_settings.get('llm_restock_use_fallback_extract', True):
|
||||
logger.debug("LLM restock fallback: disabled in settings")
|
||||
return None
|
||||
|
||||
try:
|
||||
from changedetectionio.llm.evaluator import _runtime_llm_config, accumulate_global_tokens, get_llm_settings
|
||||
from changedetectionio.llm.evaluator import get_llm_config, accumulate_global_tokens
|
||||
from changedetectionio.llm import client as llm_client
|
||||
except ImportError as e:
|
||||
logger.debug(f"LLM restock fallback: LLM libraries not available ({e})")
|
||||
return None
|
||||
|
||||
# Gate on the user setting (default True — enabled out of the box)
|
||||
if not get_llm_settings(datastore).restock_use_fallback_extract:
|
||||
logger.debug("LLM restock fallback: disabled in settings")
|
||||
return None
|
||||
|
||||
# _runtime_llm_config returns None (with a debug log) when the master 'llm_enabled'
|
||||
# toggle is off, so this path is gated for free.
|
||||
llm_cfg = _runtime_llm_config(datastore)
|
||||
llm_cfg = get_llm_config(datastore)
|
||||
if not llm_cfg or not llm_cfg.get('model'):
|
||||
logger.debug("LLM restock fallback: no LLM model configured or LLM disabled, skipping")
|
||||
logger.debug("LLM restock fallback: no LLM model configured, skipping")
|
||||
return None
|
||||
|
||||
text_content = _strip_html(content) if content else ''
|
||||
@@ -236,10 +234,7 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
|
||||
],
|
||||
api_key=llm_cfg.get('api_key'),
|
||||
api_base=llm_cfg.get('api_base'),
|
||||
# 80 fits a {price, currency, availability} JSON answer comfortably for cloud
|
||||
# models. Local reasoning models burn most of that on chain-of-thought before
|
||||
# the JSON lands — the multiplier scales it up only when provider_kind says so.
|
||||
max_tokens=apply_local_token_multiplier(80, llm_cfg),
|
||||
max_tokens=80,
|
||||
)
|
||||
|
||||
accumulate_global_tokens(
|
||||
|
||||
@@ -564,15 +564,10 @@ class perform_site_check(difference_detection_processor):
|
||||
# Main detection method
|
||||
fetched_md5 = None
|
||||
|
||||
# Record this check's price as 'last_price'. The freshly scraped itemprop never carries
|
||||
# last_price, so this is (re)set on every check - i.e. last_price always holds the price
|
||||
# from the most recent check, and at comparison time below it is the PREVIOUS check's price.
|
||||
if itemprop_availability and itemprop_availability.get('price') and not itemprop_availability.get('last_price'):
|
||||
itemprop_availability['last_price'] = itemprop_availability.get('price')
|
||||
update_obj['restock']["last_price"] = itemprop_availability.get('price')
|
||||
logger.debug(
|
||||
f"{watch.get('uuid')} Updating price - setting 'last_price' to '{itemprop_availability.get('price')}' "
|
||||
f"(previously stored 'last_price' was '{(watch.get('restock') or {}).get('last_price')}'). ")
|
||||
# store original price if not set
|
||||
if itemprop_availability and itemprop_availability.get('price') and not itemprop_availability.get('original_price'):
|
||||
itemprop_availability['original_price'] = itemprop_availability.get('price')
|
||||
update_obj['restock']["original_price"] = itemprop_availability.get('price')
|
||||
|
||||
if not self.fetcher.instock_data and not itemprop_availability.get('availability') and not itemprop_availability.get('price'):
|
||||
raise ProcessorException(
|
||||
@@ -622,13 +617,9 @@ class perform_site_check(difference_detection_processor):
|
||||
|
||||
if restock_settings.get('follow_price_changes') and watch.get('restock') and update_obj.get('restock') and update_obj['restock'].get('price'):
|
||||
price = float(update_obj['restock'].get('price'))
|
||||
# Compare against last_price (the price from the previous check)
|
||||
if watch['restock'].get('last_price'):
|
||||
previous_price = float(watch['restock'].get('last_price'))
|
||||
logger.debug(
|
||||
f"{watch.get('uuid')} Comparing NEW price '{price}' against stored 'last_price' '{previous_price}' "
|
||||
f"(watch's stored current price was '{(watch.get('restock') or {}).get('price')}') -> "
|
||||
f"price {'CHANGED' if price != previous_price else 'unchanged'}")
|
||||
# Default to current price if no previous price found
|
||||
if watch['restock'].get('original_price'):
|
||||
previous_price = float(watch['restock'].get('original_price'))
|
||||
# It was different, but negate it further down
|
||||
if price != previous_price:
|
||||
changed_detected = True
|
||||
@@ -651,14 +642,11 @@ class perform_site_check(difference_detection_processor):
|
||||
else:
|
||||
logger.trace(f"{watch.get('uuid')} {price} is between {min_limit} and {max_limit}, continuing normal comparison")
|
||||
|
||||
# Price comparison by % - against last_price (the previous check's price)
|
||||
if watch['restock'].get('last_price') and changed_detected and restock_settings.get('price_change_threshold_percent'):
|
||||
previous_price = float(watch['restock'].get('last_price'))
|
||||
# Price comparison by %
|
||||
if watch['restock'].get('original_price') and changed_detected and restock_settings.get('price_change_threshold_percent'):
|
||||
previous_price = float(watch['restock'].get('original_price'))
|
||||
pc = float(restock_settings.get('price_change_threshold_percent'))
|
||||
change = abs((price - previous_price) / previous_price * 100)
|
||||
logger.debug(
|
||||
f"{watch.get('uuid')} % threshold check - comparing NEW price '{price}' against stored "
|
||||
f"'last_price' '{previous_price}' = {change:.3f}% change (threshold {pc}%)")
|
||||
if change and change <= pc:
|
||||
logger.debug(f"{watch.get('uuid')} Override change-detected to FALSE because % threshold ({pc}%) was {change:.3f}%")
|
||||
changed_detected = False
|
||||
|
||||
@@ -35,50 +35,6 @@ def _task(watch, update_handler):
|
||||
return text_after_filter
|
||||
|
||||
|
||||
def _compute_ignore_line_numbers_for_preview(text_pre_extract, ignore_patterns, extract_patterns):
|
||||
"""1-indexed output line numbers in the post-extract display that correspond
|
||||
to input lines matching ignore_text patterns.
|
||||
|
||||
Needed because extract_text (#4138) transforms line content — e.g. "0.54.10"
|
||||
becomes ".54.10" — so a substring match for "0.54.10" against the post-extract
|
||||
text fails and the preview UI can no longer mark the line as ignored. We find
|
||||
the ignored line numbers in the pre-extract text and replay extract_by_regex
|
||||
line-by-line to map them forward.
|
||||
"""
|
||||
from changedetectionio import html_tools
|
||||
from changedetectionio.processors.text_json_diff.processor import ContentTransformer
|
||||
|
||||
if not text_pre_extract or not ignore_patterns:
|
||||
return []
|
||||
|
||||
ignored_input_lines = set(
|
||||
html_tools.strip_ignore_text(
|
||||
content=text_pre_extract,
|
||||
wordlist=ignore_patterns,
|
||||
mode='line numbers'
|
||||
)
|
||||
)
|
||||
if not ignored_input_lines:
|
||||
return []
|
||||
|
||||
if not extract_patterns:
|
||||
return sorted(ignored_input_lines)
|
||||
|
||||
# Replay extract_by_regex per-line. Each emitted match ends with exactly one
|
||||
# '\n', so counting newlines tells us how many output lines this input produced.
|
||||
output_line_counter = 0
|
||||
result = []
|
||||
for input_idx, line in enumerate(text_pre_extract.splitlines()):
|
||||
is_ignored = (input_idx + 1) in ignored_input_lines
|
||||
matches_in_line = ContentTransformer.extract_by_regex(line, extract_patterns).count('\n')
|
||||
for _ in range(matches_in_line):
|
||||
output_line_counter += 1
|
||||
if is_ignored:
|
||||
result.append(output_line_counter)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def prepare_filter_prevew(datastore, watch_uuid, form_data):
|
||||
'''Used by @app.route("/edit/<uuid_str:uuid>/preview-rendered", methods=['POST'])'''
|
||||
from changedetectionio import forms, html_tools
|
||||
@@ -94,7 +50,6 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
|
||||
|
||||
text_after_filter = ''
|
||||
text_before_filter = ''
|
||||
text_pre_extract = ''
|
||||
trigger_line_numbers = []
|
||||
ignore_line_numbers = []
|
||||
blocked_line_numbers = []
|
||||
@@ -134,22 +89,15 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
|
||||
update_handler.fetcher.content = str(decompressed_data) # str() because playwright/puppeteer/requests return string
|
||||
update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type')
|
||||
|
||||
# Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk.
|
||||
# The third task runs with extract_text cleared so we can compute ignore_line_numbers
|
||||
# against the pre-extract text (extract_text transforms lines so post-extract substring
|
||||
# matching for ignore patterns would otherwise fail — see #4138 follow-up).
|
||||
# Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk
|
||||
# Do this as parallel threads (not processes) to avoid pickle issues with Lock objects
|
||||
tmp_watch_no_extract = deepcopy(tmp_watch)
|
||||
tmp_watch_no_extract['extract_text'] = []
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future1 = executor.submit(_task, tmp_watch, update_handler)
|
||||
future2 = executor.submit(_task, blank_watch_no_filters, update_handler)
|
||||
future3 = executor.submit(_task, tmp_watch_no_extract, update_handler)
|
||||
|
||||
text_after_filter = future1.result()
|
||||
text_before_filter = future2.result()
|
||||
text_pre_extract = future3.result()
|
||||
except Exception as e:
|
||||
x=1
|
||||
|
||||
@@ -163,11 +111,10 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
|
||||
|
||||
try:
|
||||
text_to_ignore = tmp_watch.get('ignore_text', []) + datastore.data['settings']['application'].get('global_ignore_text', [])
|
||||
ignore_line_numbers = _compute_ignore_line_numbers_for_preview(
|
||||
text_pre_extract=text_pre_extract,
|
||||
ignore_patterns=text_to_ignore,
|
||||
extract_patterns=tmp_watch.get('extract_text', [])
|
||||
)
|
||||
ignore_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
|
||||
wordlist=text_to_ignore,
|
||||
mode='line numbers'
|
||||
)
|
||||
except Exception as e:
|
||||
text_before_filter = f"Error: {str(e)}"
|
||||
|
||||
|
||||
@@ -210,23 +210,10 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
|
||||
llm_summary_prompt = ''
|
||||
if llm_configured:
|
||||
try:
|
||||
from changedetectionio.llm.evaluator import (
|
||||
get_effective_summary_prompt, build_summary_cache_prompt,
|
||||
)
|
||||
from changedetectionio.llm.evaluator import get_effective_summary_prompt
|
||||
_prompt = get_effective_summary_prompt(watch, datastore)
|
||||
llm_summary_prompt = _prompt
|
||||
# Must match the cache_prompt the worker writes and the UI ajax route reads —
|
||||
# using UI default diff prefs so the initial render finds the worker's pre-cache.
|
||||
from changedetectionio.llm.evaluator import get_llm_settings
|
||||
_ls = get_llm_settings(datastore)
|
||||
_max_summary_tokens = _ls.max_summary_tokens
|
||||
_llm_model = _ls.model
|
||||
_cache_prompt = build_summary_cache_prompt(
|
||||
effective_prompt=_prompt,
|
||||
max_summary_tokens=_max_summary_tokens,
|
||||
model=_llm_model,
|
||||
)
|
||||
llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_cache_prompt)
|
||||
llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_prompt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load llm-diff-summary for {uuid}: {e}")
|
||||
|
||||
|
||||
@@ -495,17 +495,16 @@ class perform_site_check(difference_detection_processor):
|
||||
# Start with content reference, avoid copy until modification
|
||||
html_content = content
|
||||
|
||||
# Apply subtractive selectors first so include filters operate on already-cleaned content.
|
||||
# Otherwise a subtractive selector that relies on ancestor context (e.g. ".main .ads")
|
||||
# cannot match after the include filter has extracted the inner element and stripped
|
||||
# the parent wrapper.
|
||||
# Apply include filters (CSS, XPath, JSON)
|
||||
# Except for plaintext (incase they tried to confuse the system, it will HTML escape
|
||||
#if not stream_content_type.is_plaintext:
|
||||
if filter_config.has_include_filters:
|
||||
html_content = content_processor.apply_include_filters(content, stream_content_type)
|
||||
|
||||
# Apply subtractive selectors
|
||||
if filter_config.has_subtractive_selectors:
|
||||
html_content = content_processor.apply_subtractive_selectors(html_content)
|
||||
|
||||
# Apply include filters (CSS, XPath, JSON)
|
||||
if filter_config.has_include_filters:
|
||||
html_content = content_processor.apply_include_filters(html_content, stream_content_type)
|
||||
|
||||
# === TEXT EXTRACTION ===
|
||||
if watch.is_source_type_url:
|
||||
# For source URLs, keep raw content
|
||||
@@ -551,43 +550,30 @@ class perform_site_check(difference_detection_processor):
|
||||
|
||||
update_obj["last_check_status"] = self.fetcher.get_last_status_code()
|
||||
|
||||
# Snapshot an ignore-applied stream BEFORE extract operations so line-level
|
||||
# ignore patterns still match original content (#4138). Otherwise an extract_text
|
||||
# regex like /(\d+\.\d+\.\d+)/ would transform "v.1.2.1" into "1.2.1" and the
|
||||
# ignore_text pattern "v" would no longer match — meaning changes to ignored
|
||||
# lines would incorrectly affect the checksum.
|
||||
text_for_checksuming = None
|
||||
if filter_config.ignore_text:
|
||||
text_for_checksuming = html_tools.strip_ignore_text(stripped_text, filter_config.ignore_text)
|
||||
|
||||
# === LINE FILTER (plain-text substring) ===
|
||||
if filter_config.extract_lines_containing:
|
||||
stripped_text = transformer.extract_lines_containing(stripped_text, filter_config.extract_lines_containing)
|
||||
if text_for_checksuming is not None:
|
||||
text_for_checksuming = transformer.extract_lines_containing(text_for_checksuming, filter_config.extract_lines_containing)
|
||||
|
||||
# === REGEX EXTRACTION ===
|
||||
if filter_config.extract_text:
|
||||
stripped_text = transformer.extract_by_regex(stripped_text, filter_config.extract_text)
|
||||
if text_for_checksuming is not None:
|
||||
text_for_checksuming = transformer.extract_by_regex(text_for_checksuming, filter_config.extract_text)
|
||||
extracted = transformer.extract_by_regex(stripped_text, filter_config.extract_text)
|
||||
stripped_text = extracted
|
||||
|
||||
# === MORE TEXT TRANSFORMATIONS ===
|
||||
if watch.get('remove_duplicate_lines'):
|
||||
stripped_text = transformer.remove_duplicate_lines(stripped_text)
|
||||
if text_for_checksuming is not None:
|
||||
text_for_checksuming = transformer.remove_duplicate_lines(text_for_checksuming)
|
||||
|
||||
if watch.get('sort_text_alphabetically'):
|
||||
stripped_text = transformer.sort_alphabetically(stripped_text)
|
||||
if text_for_checksuming is not None:
|
||||
text_for_checksuming = transformer.sort_alphabetically(text_for_checksuming)
|
||||
|
||||
# === CHECKSUM CALCULATION ===
|
||||
if text_for_checksuming is None:
|
||||
text_for_checksuming = stripped_text
|
||||
else:
|
||||
# Optionally remove ignored lines from displayed output too
|
||||
text_for_checksuming = stripped_text
|
||||
|
||||
# Apply ignore_text for checksum calculation
|
||||
if filter_config.ignore_text:
|
||||
text_for_checksuming = html_tools.strip_ignore_text(stripped_text, filter_config.ignore_text)
|
||||
|
||||
# Optionally remove ignored lines from output
|
||||
strip_ignored_lines = watch.get('strip_ignored_lines')
|
||||
if strip_ignored_lines is None:
|
||||
strip_ignored_lines = self.datastore.data['settings']['application'].get('strip_ignored_lines')
|
||||
|
||||
@@ -187,30 +187,6 @@ $(document).ready(function() {
|
||||
confirmText: $element.attr('data-confirm-button') || 'Confirm',
|
||||
cancelText: $element.attr('data-cancel-button') || 'Cancel',
|
||||
onConfirm: function() {
|
||||
// data-method="POST" — build a body-level hidden form with the CSRF
|
||||
// token and submit it. Avoids nested-form HTML invalidity when the
|
||||
// anchor lives inside an outer <form> (e.g. settings tabs). The CSRF
|
||||
// token comes from the global `csrftoken` set in base.html.
|
||||
// GHSA-g36r-fm2p-87xm: anchors that mutate server state must not fire
|
||||
// on a bare GET, since <img src=...> CSRF relies on GET firing.
|
||||
const method = ($element.attr('data-method') || 'GET').toUpperCase();
|
||||
if (method === 'POST') {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = url;
|
||||
form.style.display = 'none';
|
||||
if (typeof csrftoken !== 'undefined' && csrftoken) {
|
||||
const tok = document.createElement('input');
|
||||
tok.type = 'hidden';
|
||||
tok.name = 'csrf_token';
|
||||
tok.value = csrftoken;
|
||||
form.appendChild(tok);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a link, navigate to the URL
|
||||
if ($element.is('a')) {
|
||||
window.location.href = url;
|
||||
|
||||
@@ -9,10 +9,6 @@ function request_textpreview_update() {
|
||||
$('textarea:visible, input:visible').each(function () {
|
||||
const $element = $(this); // Cache the jQuery object for the current element
|
||||
const name = $element.attr('name'); // Get the name attribute of the element
|
||||
// Radios share a name across multiple inputs; .val() returns the value
|
||||
// attribute regardless of checked state, so iterating would let the last
|
||||
// unchecked radio overwrite the user's actual selection. Skip unchecked.
|
||||
if ($element.is(':radio') && !$element.is(':checked')) return;
|
||||
data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val();
|
||||
});
|
||||
|
||||
|
||||
@@ -743,7 +743,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
current_watch_count = len(self.__data['watching'])
|
||||
if current_watch_count >= page_watch_limit:
|
||||
logger.error(f"Watch limit reached: {current_watch_count}/{page_watch_limit} watches. Cannot add {url}")
|
||||
flash(gettext("Watch limit reached ({current}/{limit} watches). Cannot add more watches.").format(current=current_watch_count, limit=page_watch_limit), 'error')
|
||||
flash(gettext("Watch limit reached ({}/{} watches). Cannot add more watches.").format(current_watch_count, page_watch_limit), 'error')
|
||||
return None
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid PAGE_WATCH_LIMIT value: {page_watch_limit}, ignoring limit check")
|
||||
|
||||
@@ -775,75 +775,3 @@ class DatastoreUpdatesMixin:
|
||||
tag.commit()
|
||||
logger.info(f"update_30: migrated tag {tag_uuid} restock_settings → processor_config_restock_diff")
|
||||
|
||||
def update_31(self):
|
||||
"""Fold any flat application.llm_* key into nested application.llm.<stripped>.
|
||||
|
||||
Before: a handful of LLM settings (llm_enabled, llm_thinking_budget, …) lived
|
||||
directly on settings.application alongside everything else, while the provider
|
||||
config (model, api_key, …) was already nested under settings.application.llm.
|
||||
Unifies them under one parent so the LLMSettings pydantic model has a single
|
||||
home to read/write.
|
||||
|
||||
Flat key wins on conflict (most-recent form-saved value). Idempotent.
|
||||
"""
|
||||
application = self.data['settings']['application']
|
||||
present = [k for k in list(application) if k.startswith('llm_')]
|
||||
if not present:
|
||||
return
|
||||
|
||||
nested = application.get('llm') or {}
|
||||
for flat in present:
|
||||
nested[flat.removeprefix('llm_')] = application.pop(flat)
|
||||
application['llm'] = nested
|
||||
logger.info(f"update_31: folded {len(present)} flat llm_* keys into application.llm.* "
|
||||
f"({', '.join(present)})")
|
||||
|
||||
def update_32(self):
|
||||
"""Drop max_tokens_per_check and rename max_tokens_cumulative → max_tokens_per_count_period.
|
||||
|
||||
max_tokens_per_check was never reachable from the UI (form field declared but
|
||||
never rendered or saved) and overlapped with the cumulative cap. Removing it.
|
||||
|
||||
max_tokens_cumulative was misleading — the field was used as a per-watch
|
||||
per-period cap, not lifetime. Renamed so the semantic is clear and so a
|
||||
future configurable period (day/week/month) doesn't force another rename.
|
||||
|
||||
Both keys are unreached from real installs (no UI path on prior releases);
|
||||
this migration is mostly for branches and devs running pre-release commits.
|
||||
"""
|
||||
llm = self.data['settings']['application'].get('llm') or {}
|
||||
if not llm:
|
||||
return
|
||||
changed = False
|
||||
if 'max_tokens_per_check' in llm:
|
||||
del llm['max_tokens_per_check']
|
||||
changed = True
|
||||
if 'max_tokens_cumulative' in llm:
|
||||
llm.setdefault('max_tokens_per_count_period', llm.pop('max_tokens_cumulative'))
|
||||
changed = True
|
||||
if changed:
|
||||
self.data['settings']['application']['llm'] = llm
|
||||
logger.info("update_32: cleaned up obsolete max_tokens_per_check / renamed max_tokens_cumulative")
|
||||
|
||||
def update_33(self):
|
||||
"""Rename restock 'original_price' -> 'last_price'.
|
||||
|
||||
The field was named 'original_price' but never held the first-seen price: it was
|
||||
re-stamped with the current price on every check (the freshly scraped itemprop never
|
||||
carries it, so the "set if not present" guard was always true). So it always held the
|
||||
price from the most recent check - i.e. the previous check's price at comparison time.
|
||||
Renamed so the stored field name matches what it actually contains. Idempotent.
|
||||
"""
|
||||
migrated = 0
|
||||
for uuid, watch in self.data['watching'].items():
|
||||
restock = watch.get('restock')
|
||||
if isinstance(restock, dict) and 'original_price' in restock:
|
||||
# last_price may already exist as the model default (None) after rehydration, so
|
||||
# only copy the old value across when last_price is still empty; then drop the old key.
|
||||
if not restock.get('last_price'):
|
||||
restock['last_price'] = restock.get('original_price')
|
||||
del restock['original_price']
|
||||
migrated += 1
|
||||
if migrated:
|
||||
logger.info(f"update_33: renamed restock.original_price -> restock.last_price on {migrated} watch(es)")
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ '{{change_datetime}}' }}</code></td>
|
||||
<td>{{ _("Date/time of the change, accepts format=, %(call)s, default is '%(default)s'", call="change_datetime(format='%A')", default="%Y-%m-%d %H:%M:%S %Z") }}</td>
|
||||
<td>{{ _('Date/time of the change, accepts format=, change_datetime(format=\'%A\')\', default is \'%Y-%m-%d %H:%M:%S %Z\'') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ '{{diff_url}}' }}</code></td>
|
||||
@@ -111,7 +111,7 @@
|
||||
<td><code>{{ '{{triggered_text}}' }}</code></td>
|
||||
<td>{{ _('Text that tripped the trigger from filters') }}</td>
|
||||
</tr>
|
||||
{% if not llm_features_disabled and settings_application and settings_application.get('llm', {}).get('model') %}
|
||||
{% 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>
|
||||
@@ -159,7 +159,7 @@
|
||||
<div class="pure-form-message-inline">
|
||||
<p>
|
||||
<strong>{{ _('Tip:') }}</strong> {{ _('Use <a target="newwindow" href="%(url)s">AppRise Notification URLs</a> for notification to just about any service!',
|
||||
url='https://github.com/caronc/apprise')|safe }} <a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}{{ _('<i>Please read the notification services wiki here for important configuration notes</i>')|safe }}</a>.<br>
|
||||
url='https://github.com/caronc/apprise')|safe }} <a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">{{ _('<i>Please read the notification services wiki here for important configuration notes</i>')|safe }}</a>.<br>
|
||||
</p>
|
||||
<div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">{{ _('Show advanced help and tips') }}</div>
|
||||
<ul style="display: none" id="advanced-help-notifications">
|
||||
|
||||
@@ -281,7 +281,6 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{% if not llm_features_disabled %}
|
||||
<!-- LLM Not Configured Modal -->
|
||||
<dialog id="llm-not-configured-modal" class="modal-dialog" aria-labelledby="llm-not-configured-modal-title">
|
||||
<div class="modal-header">
|
||||
@@ -295,7 +294,6 @@
|
||||
<button type="button" class="pure-button" id="close-llm-not-configured-modal">{{ _('Close') }}</button>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
|
||||
<!-- Search Modal -->
|
||||
{% if current_user.is_authenticated or not has_password %}
|
||||
|
||||
@@ -9,7 +9,6 @@ xpath://body/div/span[contains(@class, 'example-class')]",
|
||||
{% if '/text()' in field %}
|
||||
<span class="pure-form-message-inline"><strong>{{ _('Note!: //text() function does not work where the <element> contains <![CDATA[]]>') }}</strong></span><br>
|
||||
{% endif %}
|
||||
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
|
||||
<span class="pure-form-message-inline">{{ _('One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.') | safe }}<br>
|
||||
<span data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">{{ _('Show advanced help and tips') }}</span><br>
|
||||
<ul id="advanced-help-selectors" style="display: none;">
|
||||
|
||||
@@ -37,12 +37,10 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="pure-menu-item menu-collapsible" id="inline-menu-extras-group">
|
||||
{% if not llm_features_disabled %}
|
||||
<button class="toggle-button toggle-ai-mode" type="button" title="{{ _('Toggle AI Mode') }}" data-llm-configured="{{ 'true' if llm_configured else 'false' }}" data-llm-settings-url="{{ url_for('settings.settings_page') }}#ai">
|
||||
<span class="visually-hidden">{{ _('Toggle AI mode') }}</span>
|
||||
{% include "svgs/ai-mode-icon.svg" %}<span class="ai-mode-label">LLM</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="toggle-button toggle-light-mode " type="button" title="{{ _('Toggle Light/Dark Mode') }}">
|
||||
<span class="visually-hidden">{{ _('Toggle light/dark mode') }}</span>
|
||||
<span class="icon-light">
|
||||
|
||||
@@ -294,82 +294,78 @@ class TestTokenBudget:
|
||||
|
||||
assert _check_token_budget(watch, cfg, tokens_this_call=10_000) is True
|
||||
|
||||
def test_per_period_limit_exceeded_returns_false(self):
|
||||
"""Per-period tokens exceeding the cap → False."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key
|
||||
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()
|
||||
watch['llm_tokens_this_period'] = 900
|
||||
watch['llm_tokens_period_key'] = _get_month_key()
|
||||
cfg = {'max_tokens_per_count_period': 1000}
|
||||
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_per_period_limit_not_yet_exceeded_returns_true(self):
|
||||
"""Per-period tokens within the cap → True."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key
|
||||
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_this_period'] = 500
|
||||
watch['llm_tokens_period_key'] = _get_month_key()
|
||||
cfg = {'max_tokens_per_count_period': 1000}
|
||||
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_period_rollover_zeroes_counter(self):
|
||||
"""Stale period_key triggers rollover: counter resets before this call's tokens are added."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key
|
||||
|
||||
watch = _make_watch()
|
||||
watch['llm_tokens_this_period'] = 999_999 # last period's giant total
|
||||
watch['llm_tokens_period_key'] = '1970-01' # ancient — guaranteed stale
|
||||
cfg = {'max_tokens_per_count_period': 1000}
|
||||
|
||||
# This call adds 100 → after rollover should be 100, under the 1000 cap
|
||||
result = _check_token_budget(watch, cfg, tokens_this_call=100)
|
||||
assert result is True
|
||||
assert watch['llm_tokens_this_period'] == 100
|
||||
assert watch['llm_tokens_period_key'] == _get_month_key()
|
||||
|
||||
def test_tokens_accumulated_into_both_counters(self):
|
||||
"""tokens_this_call increments both the lifetime stat and the per-period counter."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key
|
||||
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
|
||||
watch['llm_tokens_this_period'] = 50
|
||||
watch['llm_tokens_period_key'] = _get_month_key()
|
||||
cfg = {}
|
||||
|
||||
_check_token_budget(watch, cfg, tokens_this_call=75)
|
||||
assert watch['llm_tokens_used_cumulative'] == 375
|
||||
assert watch['llm_tokens_this_period'] == 125
|
||||
|
||||
def test_zero_tokens_call_does_not_change_counters(self):
|
||||
"""Calling with tokens_this_call=0 (pre-flight check) doesn't modify counters."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key
|
||||
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
|
||||
watch['llm_tokens_this_period'] = 80
|
||||
watch['llm_tokens_period_key'] = _get_month_key()
|
||||
cfg = {}
|
||||
|
||||
_check_token_budget(watch, cfg, tokens_this_call=0)
|
||||
assert watch['llm_tokens_used_cumulative'] == 200
|
||||
assert watch['llm_tokens_this_period'] == 80
|
||||
|
||||
def test_evaluate_change_skips_call_when_per_period_over_budget(self):
|
||||
"""Pre-flight check: if already over the period cap, skip the LLM call and fail open."""
|
||||
from changedetectionio.llm.evaluator import evaluate_change, _get_month_key
|
||||
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_per_count_period': 100})
|
||||
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'max_tokens_cumulative': 100})
|
||||
watch = _make_watch(llm_intent='flag price drops')
|
||||
watch['llm_tokens_this_period'] = 500 # already far over
|
||||
watch['llm_tokens_period_key'] = _get_month_key()
|
||||
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')
|
||||
@@ -378,6 +374,23 @@ class TestTokenBudget:
|
||||
# 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)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"""
|
||||
Smoke test for the LLM_FEATURES_DISABLED env var.
|
||||
|
||||
The env var is intended to hide every LLM/AI surface (settings tab, edit tab,
|
||||
base-template AI toggle/modal) for hosted deployments. This test renders the
|
||||
three primary pages with the env var set and verifies that none of the
|
||||
LLM-related markers leak through.
|
||||
"""
|
||||
from flask import url_for
|
||||
|
||||
|
||||
def _llm_markers_absent(body: bytes, where: str = ''):
|
||||
"""All of these strings appear in LLM UI surfaces — none should render."""
|
||||
for marker in (b'AI / LLM', b'toggle-ai-mode', b'llm-not-configured-modal',
|
||||
b'id="ai-llm"', b'#ai-llm', b'href="#ai"'):
|
||||
if marker in body:
|
||||
idx = body.find(marker)
|
||||
context = body[max(0, idx - 80):idx + len(marker) + 80].decode('utf-8', 'replace')
|
||||
raise AssertionError(f"[{where}] {marker!r} found in body, context: ...{context}...")
|
||||
|
||||
|
||||
def test_llm_features_disabled_hides_ui(client, live_server, monkeypatch):
|
||||
monkeypatch.setenv('LLM_FEATURES_DISABLED', 'true')
|
||||
|
||||
# Sanity: helper reports the env var is in effect
|
||||
from changedetectionio.llm.evaluator import is_llm_features_disabled, get_llm_config
|
||||
assert is_llm_features_disabled() is True
|
||||
# get_llm_config() must return None so every `if llm_configured` template hides
|
||||
datastore = client.application.config.get('DATASTORE')
|
||||
assert get_llm_config(datastore) is None
|
||||
|
||||
# 1. Watch list (base.html + menu.html surface)
|
||||
res = client.get(url_for('watchlist.index'))
|
||||
assert res.status_code == 200
|
||||
_llm_markers_absent(res.data, where='watchlist')
|
||||
|
||||
# 2. Settings page (should not have an AI / LLM tab or the LLM tab body)
|
||||
res = client.get(url_for('settings.settings_page'))
|
||||
assert res.status_code == 200
|
||||
_llm_markers_absent(res.data, where='settings')
|
||||
|
||||
# 3. Edit page for a watch (should not have an AI / LLM tab or include_llm_intent body)
|
||||
uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Disabled LLM watch'})
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=uuid))
|
||||
assert res.status_code == 200
|
||||
_llm_markers_absent(res.data, where='edit')
|
||||
# The watch-edit-only intent textarea should also be absent
|
||||
assert b'name="llm_intent"' not in res.data
|
||||
assert b'name="llm_change_summary"' not in res.data
|
||||
|
||||
|
||||
def test_llm_features_enabled_by_default(client, live_server, monkeypatch):
|
||||
"""When LLM_FEATURES_DISABLED is unset, the AI / LLM surfaces are still rendered."""
|
||||
monkeypatch.delenv('LLM_FEATURES_DISABLED', raising=False)
|
||||
|
||||
from changedetectionio.llm.evaluator import is_llm_features_disabled
|
||||
assert is_llm_features_disabled() is False
|
||||
|
||||
res = client.get(url_for('settings.settings_page'))
|
||||
assert res.status_code == 200
|
||||
# The AI / LLM settings tab anchor should be present when not disabled
|
||||
assert b'href="#ai"' in res.data
|
||||
@@ -14,9 +14,8 @@ def _make_datastore(llm_model='gpt-4o-mini', enabled=True):
|
||||
ds.data = {
|
||||
'settings': {
|
||||
'application': {
|
||||
'llm_restock_use_fallback_extract': enabled,
|
||||
'llm': {
|
||||
'enabled': True,
|
||||
'restock_use_fallback_extract': enabled,
|
||||
'model': llm_model,
|
||||
'api_key': 'test-key',
|
||||
'api_base': '',
|
||||
@@ -85,8 +84,8 @@ class TestLLMRestockPluginDisabled:
|
||||
ds.data = {
|
||||
'settings': {
|
||||
'application': {
|
||||
# No 'llm' key → get_llm_config returns None;
|
||||
# restock_use_fallback_extract still defaults to True via LLMSettings
|
||||
'llm_restock_use_fallback_extract': True,
|
||||
# No 'llm' key → get_llm_config returns None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +108,7 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas
|
||||
html_content = html_part.get_content()
|
||||
assert 'some text<br>' in html_content # We converted \n from the notification body
|
||||
assert 'fallback-body<br>' in html_content # kept the original <br>
|
||||
# GHSA-q8xq-qg4x-wphg: apostrophes in diff content are escaped (') for HTML notifications.
|
||||
# Renders as ' in the recipient's email client; only the byte-source differs.
|
||||
assert '(added) So let's see what happens.<br>' in html_content # the html part
|
||||
assert '(added) So let\'s see what happens.<br>' in html_content # the html part
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
@@ -454,8 +452,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
|
||||
html_part = parts[1]
|
||||
assert html_part.get_content_type() == 'text/html'
|
||||
html_content = html_part.get_content()
|
||||
# GHSA-q8xq-qg4x-wphg: apostrophes in diff content are escaped (') for HTML notifications.
|
||||
assert '(removed) So let's see what happens.' in html_content # the html part
|
||||
assert '(removed) So let\'s see what happens.' in html_content # the html part
|
||||
assert '<!DOCTYPE html' not in html_content
|
||||
assert '<!DOCTYPE html' in html_content # Our original template is working correctly
|
||||
|
||||
@@ -795,6 +792,5 @@ def test_check_html_notification_with_apprise_format_is_html(client, live_server
|
||||
html_content = html_part.get_content()
|
||||
assert 'some text<br>' in html_content # We converted \n from the notification body
|
||||
assert 'fallback-body<br>' in html_content # kept the original <br>
|
||||
# GHSA-q8xq-qg4x-wphg: apostrophes in diff content are escaped (') for HTML notifications.
|
||||
assert '(added) So let's see what happens.<br>' in html_content # the html part
|
||||
assert '(added) So let\'s see what happens.<br>' in html_content # the html part
|
||||
delete_all_watches(client)
|
||||
@@ -48,32 +48,6 @@ def test_check_access_control(app, client, live_server, measure_memory_usage, da
|
||||
res = c.get(url_for("ui.ui_diff.diff_history_page", uuid="first"))
|
||||
assert b'Random content' in res.data
|
||||
|
||||
# GHSA-vwgh-2hvh-4xm5: shared_diff_access only covers the read-only
|
||||
# diff page — the extract endpoints (which run an attacker-supplied
|
||||
# regex against history and write a CSV to disk) must still require
|
||||
# auth even when the share flag is enabled.
|
||||
res = c.get(url_for("ui.ui_diff.diff_history_page_extract_GET", uuid="first"))
|
||||
assert res.status_code == 302, "Extract form GET must redirect to login for anonymous users"
|
||||
assert b'/login' in res.data or b'login' in res.headers.get('Location', '').encode()
|
||||
|
||||
res = c.post(
|
||||
url_for("ui.ui_diff.diff_history_page_extract_POST", uuid="first"),
|
||||
data={"extract_regex": ".*", "extract_submit_button": "Extract as CSV"},
|
||||
)
|
||||
assert res.status_code == 302, "Extract POST must redirect to login for anonymous users"
|
||||
assert b'login' in res.headers.get('Location', '').encode()
|
||||
|
||||
# But sub-resources the diff page legitimately loads should still pass the gate.
|
||||
# download_patch is linked from diff.html — anonymous viewers must be able to fetch it.
|
||||
# (We don't care about the body here, just that auth doesn't block it.)
|
||||
res = c.get(url_for("ui.ui_diff.download_patch", uuid="first"))
|
||||
assert res.status_code != 302, "download_patch must be reachable for shared diff viewers"
|
||||
|
||||
# processor_asset (used for screenshots embedded in image_ssim_diff watches) must also be reachable.
|
||||
# For a text watch the processor has no such asset so 404 is fine — what matters is no auth redirect.
|
||||
res = c.get(url_for("ui.ui_diff.processor_asset", uuid="first", asset_name="before"))
|
||||
assert res.status_code != 302, "processor_asset must be reachable for shared diff viewers"
|
||||
|
||||
# access to assets should work (check_authentication)
|
||||
res = c.get(url_for('static_content', group='js', filename='jquery-3.6.0.min.js'))
|
||||
assert res.status_code == 200
|
||||
|
||||
@@ -102,8 +102,6 @@ def test_api_simple(client, live_server, measure_memory_usage, datastore_path):
|
||||
#705 `last_changed` should be zero on the first check
|
||||
assert before_recheck_info['last_changed'] == 0
|
||||
assert before_recheck_info['title'] == 'My test URL'
|
||||
assert isinstance(before_recheck_info['link'], str), "link must be a plain string, not a tuple or list"
|
||||
assert before_recheck_info['link'] == test_url
|
||||
|
||||
# Check the limit by tag doesnt return anything when nothing found
|
||||
res = client.get(
|
||||
@@ -406,106 +404,6 @@ def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path
|
||||
"extract_lines_containing should be persisted and returned via API"
|
||||
|
||||
|
||||
def test_api_strips_internal_fields(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Internal/transient fields must never cross the API boundary in either direction:
|
||||
1. `__`-prefixed keys (e.g. `__check_status` set by the worker for UI status)
|
||||
2. System-managed fields not in the OpenAPI spec (see SYSTEM_MANAGED_NON_SPEC_FIELDS):
|
||||
`last_check_status`, `last_filter_config_hash`, `_llm_*`, `llm_*`, etc.
|
||||
|
||||
GET responses must strip them. PUT/POST payloads must silently discard them.
|
||||
Without this, a client that round-trips GET → PUT trips the unknown-field validator.
|
||||
"""
|
||||
from changedetectionio.model.schema_utils import SYSTEM_MANAGED_NON_SPEC_FIELDS
|
||||
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
# Create
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": test_url}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert res.status_code == 201
|
||||
watch_uuid = res.json.get('uuid')
|
||||
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Force both a transient __-prefixed and a system-managed field onto the watch,
|
||||
# simulating worker/processor-set state.
|
||||
watch_obj = datastore.data['watching'][watch_uuid]
|
||||
watch_obj['__check_status'] = 'Fetching page..'
|
||||
watch_obj['last_check_status'] = 200
|
||||
watch_obj['_llm_result'] = {'summary': 'cached llm output'}
|
||||
watch_obj['last_filter_config_hash'] = 'abc123'
|
||||
|
||||
# --- GET must strip all internal fields ---
|
||||
res = client.get(
|
||||
url_for("watch", uuid=watch_uuid),
|
||||
headers={'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert not any(k.startswith('__') for k in res.json.keys()), \
|
||||
f"No __-prefixed field should leak into API responses; got keys: {list(res.json.keys())}"
|
||||
leaked_system_fields = SYSTEM_MANAGED_NON_SPEC_FIELDS & set(res.json.keys())
|
||||
assert not leaked_system_fields, \
|
||||
f"System-managed non-spec fields must not appear in GET response; leaked: {leaked_system_fields}"
|
||||
|
||||
# --- PUT must accept (and silently drop) those same internal fields ---
|
||||
# This is the key round-trip property: a client should be able to PUT back what it just GET'd.
|
||||
# Use the actual GET response as the payload (the realistic round-trip case).
|
||||
payload = dict(res.json)
|
||||
payload['__check_status'] = 'attacker-supplied value' # not in the GET, but a client could add it
|
||||
payload['last_check_status'] = 999 # ditto
|
||||
payload['_llm_result'] = 'attacker overwrite'
|
||||
res = client.put(
|
||||
url_for("watch", uuid=watch_uuid),
|
||||
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||
data=json.dumps(payload),
|
||||
)
|
||||
assert res.status_code == 200, \
|
||||
f"PUT round-tripping GET response plus internal fields should succeed (got {res.status_code}: {res.data!r})"
|
||||
|
||||
# Internal fields must not have been overwritten by the PUT
|
||||
assert watch_obj.get('__check_status') == 'Fetching page..', \
|
||||
"PUT must not overwrite __-prefixed fields"
|
||||
assert watch_obj.get('_llm_result') == {'summary': 'cached llm output'}, \
|
||||
"PUT must not overwrite system-managed non-spec fields"
|
||||
|
||||
# --- POST must also silently discard internal fields ---
|
||||
# Use unique sentinel values so we can distinguish "POST persisted my value" from
|
||||
# "the worker concurrently re-set the field while processing the new watch".
|
||||
attacker_check_status = 'attacker-sentinel-__check_status-9f7c'
|
||||
attacker_llm_result = 'attacker-sentinel-_llm_result-9f7c'
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({
|
||||
"url": test_url + "?2",
|
||||
"__check_status": attacker_check_status,
|
||||
"_llm_result": attacker_llm_result,
|
||||
}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 201, \
|
||||
f"POST with internal fields should succeed (got {res.status_code}: {res.data!r})"
|
||||
new_uuid = res.json.get('uuid')
|
||||
new_watch = datastore.data['watching'][new_uuid]
|
||||
# If POST had persisted the attacker payload these specific sentinel values would remain.
|
||||
# The worker may legitimately re-set __check_status with its own status string, that's fine.
|
||||
assert new_watch.get('__check_status') != attacker_check_status, \
|
||||
"POST must not persist __-prefixed fields from input"
|
||||
assert new_watch.get('_llm_result') != attacker_llm_result, \
|
||||
"POST must not persist system-managed fields from input"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_access_denied(client, live_server, measure_memory_usage, datastore_path):
|
||||
# `config_api_token_enabled` Should be On by default
|
||||
res = client.get(
|
||||
@@ -1005,101 +903,6 @@ def test_api_restock_processor_config(client, live_server, measure_memory_usage,
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_api_watch_get_returns_resolved_restock_processor_config(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
GET /api/v1/watch/{uuid} must include processor_config_restock_diff and
|
||||
processor_config_restock_diff_source in the response.
|
||||
|
||||
Two cases:
|
||||
- Watch-level config only: source == 'watch', config reflects the watch's own settings.
|
||||
- Tag with overrides_watch=True: source == 'tag:<uuid>', config reflects the tag's settings
|
||||
regardless of what the watch itself has stored.
|
||||
"""
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
# --- Case 1: watch-level config, no tag override ---
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({
|
||||
"url": test_url,
|
||||
"processor": "restock_diff",
|
||||
"processor_config_restock_diff": {
|
||||
"in_stock_processing": "all_changes",
|
||||
"follow_price_changes": False,
|
||||
"price_change_min": 1.23,
|
||||
}
|
||||
}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
watch_uuid = res.json.get('uuid')
|
||||
|
||||
res = client.get(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
|
||||
assert res.status_code == 200
|
||||
data = res.json
|
||||
assert 'processor_config_restock_diff' in data, "GET should include processor_config_restock_diff"
|
||||
assert 'processor_config_restock_diff_source' in data, "GET should include processor_config_restock_diff_source"
|
||||
assert data['processor_config_restock_diff_source'] == 'watch'
|
||||
assert data['processor_config_restock_diff'].get('in_stock_processing') == 'all_changes'
|
||||
assert data['processor_config_restock_diff'].get('follow_price_changes') == False
|
||||
assert data['processor_config_restock_diff'].get('price_change_min') == 1.23
|
||||
|
||||
# --- Case 2: tag with overrides_watch=True overrides watch-level config ---
|
||||
res = client.post(
|
||||
url_for("tag"),
|
||||
data=json.dumps({
|
||||
"title": "Override tag",
|
||||
"overrides_watch": True,
|
||||
"processor_config_restock_diff": {
|
||||
"in_stock_processing": "in_stock_only",
|
||||
"follow_price_changes": True,
|
||||
"price_change_min": 999.0,
|
||||
}
|
||||
}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
tag_uuid = res.json.get('uuid')
|
||||
|
||||
# Assign the tag to the watch
|
||||
res = client.put(
|
||||
url_for("watch", uuid=watch_uuid),
|
||||
data=json.dumps({"tags": [tag_uuid]}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
res = client.get(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
|
||||
assert res.status_code == 200
|
||||
data = res.json
|
||||
assert data['processor_config_restock_diff_source'] == f'tag:{tag_uuid}', \
|
||||
"Source should show the overriding tag UUID"
|
||||
assert data['processor_config_restock_diff'].get('in_stock_processing') == 'in_stock_only', \
|
||||
"Tag config should override watch-level config"
|
||||
assert data['processor_config_restock_diff'].get('price_change_min') == 999.0, \
|
||||
"Tag price_change_min should override watch-level value"
|
||||
|
||||
# processor_config_restock_diff is readonly — PUT attempts to set the resolved field should be
|
||||
# silently ignored (the field is stripped before the watch is updated, same as other readOnly fields)
|
||||
res = client.put(
|
||||
url_for("watch", uuid=watch_uuid),
|
||||
data=json.dumps({"processor_config_restock_diff": {"in_stock_processing": "off"}}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
# PUT with processor_config_restock_diff is still valid (sets watch-level config),
|
||||
# but the GET response continues to reflect the tag override
|
||||
assert res.status_code == 200
|
||||
res = client.get(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
|
||||
data = res.json
|
||||
assert data['processor_config_restock_diff_source'] == f'tag:{tag_uuid}', \
|
||||
"Tag override should still be active after PUT"
|
||||
assert data['processor_config_restock_diff'].get('in_stock_processing') == 'in_stock_only', \
|
||||
"Tag config should still win after PUT attempted to change watch-level config"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_api_conflict_UI_password(client, live_server, measure_memory_usage, datastore_path):
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import json
|
||||
import threading
|
||||
import uuid as uuid_module
|
||||
from flask import url_for
|
||||
from .util import live_server_setup, wait_for_all_checks, wait_for_watch_history, delete_all_watches
|
||||
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
|
||||
import os
|
||||
|
||||
|
||||
@@ -653,80 +653,6 @@ def test_api_history_edge_cases(client, live_server, measure_memory_usage, datas
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_api_history_html_does_not_serve_as_text_html(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
GHSA-cgj8-g98g-4p9x: GET /api/v1/watch/<uuid>/history/<timestamp>?html=true
|
||||
must not serve the stored snapshot with Content-Type: text/html. The bytes
|
||||
are an external site's HTML — if the response is labelled text/html, a
|
||||
<script> the attacker planted on that site executes in our origin when an
|
||||
operator opens the URL in a browser (stored XSS).
|
||||
|
||||
The fix is text/plain; charset=utf-8 + X-Content-Type-Options: nosniff so
|
||||
browsers render inert text and can't sniff back to HTML/UTF-7. API clients
|
||||
don't care about Content-Type and still receive the same bytes.
|
||||
|
||||
This test injects the snapshot directly via Watch.save_history_blob() and
|
||||
save_last_fetched_html() so we exercise the API endpoint's response
|
||||
shaping without depending on the live-fetch pipeline.
|
||||
"""
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": test_url}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
watch_uuid = res.json.get('uuid')
|
||||
|
||||
# Plant a payload that would execute if the response were rendered as HTML.
|
||||
malicious_html = (
|
||||
"<html><body>"
|
||||
"<script>window.__CD_XSS_PROBE = 1</script>"
|
||||
"<img src=x onerror=\"window.__CD_XSS_PROBE = 1\">"
|
||||
"</body></html>"
|
||||
)
|
||||
ts = '1700000000'
|
||||
watch = live_server.app.config['DATASTORE'].data['watching'][watch_uuid]
|
||||
watch.save_history_blob(contents=malicious_html, timestamp=ts, snapshot_id=ts)
|
||||
watch.save_last_fetched_html(timestamp=ts, contents=malicious_html)
|
||||
|
||||
# The actual XSS-relevant assertion: how is the snapshot served?
|
||||
res = client.get(
|
||||
url_for("watchsinglehistory", uuid=watch_uuid, timestamp=ts) + '?html=true',
|
||||
headers={'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 200, f"unexpected status {res.status_code}: {res.data!r}"
|
||||
|
||||
ctype = res.headers.get('Content-Type', '')
|
||||
assert 'text/html' not in ctype, \
|
||||
f"snapshot must not be served as text/html (got {ctype!r}) — see GHSA-cgj8-g98g-4p9x"
|
||||
# Explicit utf-8 closes the UTF-7 sniffing bypass — without a charset, some
|
||||
# browsers will auto-detect UTF-7 from byte patterns and a crafted snapshot
|
||||
# can still execute via `+ADw-script+AD4-...`
|
||||
assert 'charset=utf-8' in ctype.lower(), \
|
||||
f"Content-Type must pin charset=utf-8 to defeat UTF-7 sniffing XSS (got {ctype!r})"
|
||||
|
||||
nosniff = res.headers.get('X-Content-Type-Options', '')
|
||||
assert nosniff.lower() == 'nosniff', \
|
||||
f"X-Content-Type-Options: nosniff required to defeat MIME-sniffing (got {nosniff!r})"
|
||||
|
||||
# Download filename should include the timestamp so multiple snapshots from
|
||||
# the same watch don't overwrite each other on disk.
|
||||
disp = res.headers.get('Content-Disposition', '')
|
||||
assert 'attachment' in disp and ts in disp, \
|
||||
f"Content-Disposition should be attachment + per-timestamp filename (got {disp!r})"
|
||||
|
||||
# API contract: the raw bytes must still be the original HTML — programmatic
|
||||
# consumers depend on getting the stored snapshot back.
|
||||
assert b'<script>' in res.data, \
|
||||
"Response body must still contain the raw stored bytes (the API contract)"
|
||||
|
||||
# Cleanup
|
||||
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_api_notification_edge_cases(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Test notification configuration edge cases.
|
||||
|
||||
@@ -251,41 +251,3 @@ body > table > tr:nth-child(3) > td:nth-child(3)""",
|
||||
# First column should exist
|
||||
assert b"Emil" in res.data
|
||||
|
||||
|
||||
# Re PR #978: subtractive_selectors must run BEFORE include_filters so that selectors
|
||||
# relying on ancestor context (e.g. ".main .ad") can still match. If include runs first,
|
||||
# the ancestor wrapper is stripped and the subtractive selector matches nothing.
|
||||
def test_subtractive_selectors_applied_before_include_filters(client, live_server, measure_memory_usage, datastore_path):
|
||||
page_html = """<html><body>
|
||||
<div class="main">
|
||||
<p class="keep">first kept paragraph</p>
|
||||
<p class="advertisement">noisy advertisement text</p>
|
||||
<p class="keep">second kept paragraph</p>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(page_html)
|
||||
|
||||
test_url = url_for("test_endpoint", _external=True)
|
||||
client.application.config.get('DATASTORE').add_watch(
|
||||
url=test_url,
|
||||
extras={
|
||||
# Include filter strips the .main wrapper from the output
|
||||
"include_filters": [".main p"],
|
||||
# Subtractive selector depends on the .main ancestor — only effective if it runs first
|
||||
"subtractive_selectors": [".main .advertisement"],
|
||||
},
|
||||
)
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.get(
|
||||
url_for("ui.ui_preview.preview_page", uuid="first"),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
assert b"first kept paragraph" in res.data
|
||||
assert b"second kept paragraph" in res.data
|
||||
# The bug: ad survives if include filter runs first
|
||||
assert b"noisy advertisement text" not in res.data
|
||||
|
||||
@@ -559,78 +559,3 @@ def test_extract_lines_containing_with_include_filters_css(client, live_server,
|
||||
assert b'forecast' not in res.data
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
# Re issue #4138: ignore_text must take effect BEFORE extract_text regex, otherwise the
|
||||
# regex transforms line content (e.g. "v.1.2.1" -> "1.2.1") and ignore_text patterns
|
||||
# like "v"/"rc" can no longer match — causing changes to ignored lines to incorrectly
|
||||
# trigger change-detection.
|
||||
def test_ignore_text_applied_before_extract_text_regex(client, live_server, measure_memory_usage, datastore_path):
|
||||
initial_data = """<html><body>
|
||||
<p>0.8.9</p>
|
||||
<p>v.1.2.1</p>
|
||||
<p>rc-1.0.0</p>
|
||||
</body></html>"""
|
||||
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(initial_data)
|
||||
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'paused': True})
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1),
|
||||
data={
|
||||
'ignore_text': 'v\r\nrc',
|
||||
'extract_text': r'/(\d+\.\d+\.\d+)/',
|
||||
"url": test_url,
|
||||
"tags": "",
|
||||
"headers": "",
|
||||
'fetch_backend': "html_requests",
|
||||
"time_between_check_use_default": "y",
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b"unpaused" in res.data
|
||||
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Bump only the IGNORED lines — these should not move the checksum
|
||||
changed_data = """<html><body>
|
||||
<p>0.8.9</p>
|
||||
<p>v.1.3.0</p>
|
||||
<p>rc-2.0.0</p>
|
||||
</body></html>"""
|
||||
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(changed_data)
|
||||
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'has-unread-changes' not in res.data, \
|
||||
"Changing only ignored lines should not trigger a change even when extract_text regex is set"
|
||||
|
||||
client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
|
||||
time.sleep(1)
|
||||
|
||||
# Now bump the non-ignored line — this SHOULD trigger
|
||||
triggered_data = """<html><body>
|
||||
<p>0.9.0</p>
|
||||
<p>v.1.3.0</p>
|
||||
<p>rc-2.0.0</p>
|
||||
</body></html>"""
|
||||
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(triggered_data)
|
||||
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'has-unread-changes' in res.data, \
|
||||
"Changing a non-ignored line should still trigger a change"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
@@ -77,82 +77,3 @@ def test_content_filter_live_preview(client, live_server, measure_memory_usage,
|
||||
assert reply.get('trigger_line_numbers') == [1] # Triggers "Awesome" in line 1
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def _setup_version_list_preview(datastore_path, client):
|
||||
"""Shared HTML fixture for #4138 preview regressions (version tag list)."""
|
||||
import time
|
||||
|
||||
data = """<html><body>
|
||||
0.55.5<br>
|
||||
0.55.4<br>
|
||||
0.55.3<br>
|
||||
0.54.10<br>
|
||||
0.54.9<br>
|
||||
</body></html>"""
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(data)
|
||||
|
||||
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 test_url, uuid
|
||||
|
||||
|
||||
def test_preview_ignore_highlight_with_extract_text(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Regression for #4138 follow-up: when extract_text rewrites a line (e.g. "0.54.10" → ".54.10"),
|
||||
the preview must still highlight that row as 'ignored' even though substring matching against the
|
||||
post-extract text fails."""
|
||||
import json
|
||||
|
||||
test_url, uuid = _setup_version_list_preview(datastore_path, client)
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
|
||||
data={
|
||||
"include_filters": "",
|
||||
"fetch_backend": 'html_requests',
|
||||
"ignore_text": "0.54.10",
|
||||
"extract_text": r"/(.\d+\.\d+)/",
|
||||
"url": test_url,
|
||||
},
|
||||
)
|
||||
reply = json.loads(res.data.decode('utf-8'))
|
||||
# The regex strips the leading "0", so the post-extract line for the ignored input is ".54.10".
|
||||
# The preview should still mark its position (line 4) as ignored.
|
||||
assert reply.get('ignore_line_numbers') == [4], \
|
||||
f"Expected line 4 to be highlighted as ignored, got {reply.get('ignore_line_numbers')!r}"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_preview_strip_ignored_lines_with_extract_text(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Regression for #4138 follow-up: with strip_ignored_lines enabled, an ignored line must be
|
||||
removed from the preview output even when extract_text would otherwise rewrite it (0.54.10 → .54.10)."""
|
||||
import json
|
||||
|
||||
test_url, uuid = _setup_version_list_preview(datastore_path, client)
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
|
||||
data={
|
||||
"include_filters": "",
|
||||
"fetch_backend": 'html_requests',
|
||||
"ignore_text": "0.54.10",
|
||||
"extract_text": r"/(.\d+\.\d+)/",
|
||||
"strip_ignored_lines": "true",
|
||||
"url": test_url,
|
||||
},
|
||||
)
|
||||
reply = json.loads(res.data.decode('utf-8'))
|
||||
after_filter = reply.get('after_filter', '')
|
||||
|
||||
assert '.54.10' not in after_filter, \
|
||||
f"Stripped ignored line should not appear in preview output, got:\n{after_filter!r}"
|
||||
assert '0.54.10' not in after_filter
|
||||
assert reply.get('ignore_line_numbers') == [], \
|
||||
f"Stripped lines need no highlight, got {reply.get('ignore_line_numbers')!r}"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
@@ -329,9 +329,9 @@ def test_settings_form_preserves_api_key_when_submitted_blank(
|
||||
res = client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-model': 'gpt-4o',
|
||||
'llm-api_key': '', # blank — PasswordField behaviour
|
||||
'llm-api_base': '',
|
||||
'llm-llm_model': 'gpt-4o',
|
||||
'llm-llm_api_key': '', # blank — PasswordField behaviour
|
||||
'llm-llm_api_base': '',
|
||||
'application-pager_size': '50',
|
||||
'application-notification_format': 'System default',
|
||||
'requests-time_between_check-days': '0',
|
||||
@@ -351,325 +351,3 @@ def test_settings_form_preserves_api_key_when_submitted_blank(
|
||||
f"Blank PasswordField submission must not clear the existing API key (got '{saved_key}')"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF — api_base must reject private/loopback/reserved hosts (GHSA-jrxm-qjfh-g54f)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Hosts that is_private_hostname() must classify as restricted.
|
||||
# 169.254.169.254 is the cloud metadata service (AWS/GCP IMDSv1).
|
||||
_SSRF_PRIVATE_HOSTS = [
|
||||
'http://127.0.0.1:6379',
|
||||
'http://localhost:11434',
|
||||
'http://10.0.0.5:8080',
|
||||
'http://192.168.1.1',
|
||||
'http://169.254.169.254',
|
||||
]
|
||||
|
||||
|
||||
def test_llm_models_endpoint_blocks_private_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""GET /settings/llm/models must refuse api_base pointing at private/loopback
|
||||
hosts and must never reach litellm."""
|
||||
# Default state — protection ON
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
|
||||
for bad in _SSRF_PRIVATE_HOSTS:
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={'provider': 'openai_compatible', 'api_base': bad},
|
||||
)
|
||||
assert res.status_code == 400, \
|
||||
f"api_base={bad!r} should have been rejected by SSRF guard"
|
||||
body = res.get_json()
|
||||
assert body['models'] == []
|
||||
assert 'ALLOW_IANA_RESTRICTED_ADDRESSES' in body['error'], \
|
||||
f"Error message should mention the env-var bypass: {body['error']!r}"
|
||||
# The raw attacker-controlled api_base must never be reflected back
|
||||
# (avoids XSS when JS renders the error into the DOM).
|
||||
assert bad not in body['error']
|
||||
|
||||
|
||||
def test_llm_test_endpoint_blocks_private_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""GET /settings/llm/test must refuse api_base pointing at private/loopback
|
||||
hosts and must never reach litellm.completion()."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
|
||||
for bad in _SSRF_PRIVATE_HOSTS:
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_test'),
|
||||
query_string={'model': 'openai/gpt-4', 'api_base': bad},
|
||||
)
|
||||
assert res.status_code == 400, \
|
||||
f"api_base={bad!r} should have been rejected by SSRF guard"
|
||||
body = res.get_json()
|
||||
assert body['ok'] is False
|
||||
assert 'ALLOW_IANA_RESTRICTED_ADDRESSES' in body['error']
|
||||
assert bad not in body['error']
|
||||
|
||||
|
||||
def test_llm_endpoints_allow_api_base_when_iana_bypass_enabled(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""When ALLOW_IANA_RESTRICTED_ADDRESSES=true the SSRF guard is bypassed so
|
||||
operators can intentionally point at a local Ollama / vLLM endpoint.
|
||||
We patch litellm so the test doesn't actually need a live model server —
|
||||
we only need to confirm the guard didn't short-circuit."""
|
||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')
|
||||
|
||||
# Stub get_valid_models so the call returns successfully without network.
|
||||
import litellm
|
||||
monkeypatch.setattr(litellm, 'get_valid_models',
|
||||
lambda **kwargs: ['llama3.2'])
|
||||
|
||||
# Supply api_key explicitly so we aren't tripped by the credential-exfil
|
||||
# guard (which refuses to substitute the stored key for a non-stored api_base).
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={'provider': 'openai_compatible',
|
||||
'api_base': 'http://127.0.0.1:11434',
|
||||
'api_key': 'sk-test-explicit'},
|
||||
)
|
||||
assert res.status_code == 200, \
|
||||
"With ALLOW_IANA_RESTRICTED_ADDRESSES=true, private api_base must be allowed"
|
||||
body = res.get_json()
|
||||
assert body['error'] is None
|
||||
assert body['models'], "Stubbed model list should be returned"
|
||||
|
||||
|
||||
def test_settings_form_rejects_private_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""The globalSettingsLLMForm validator must block private api_base values
|
||||
when ALLOW_IANA_RESTRICTED_ADDRESSES is not set, and must NOT persist them
|
||||
to the datastore."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
# Make sure no stale api_base exists from previous tests.
|
||||
ds.data['settings']['application'].pop('llm', None)
|
||||
|
||||
res = client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-model': 'gpt-4o',
|
||||
'llm-api_key': '',
|
||||
'llm-api_base': 'http://127.0.0.1:11434',
|
||||
'application-pager_size': '50',
|
||||
'application-notification_format': 'System default',
|
||||
'requests-time_between_check-days': '0',
|
||||
'requests-time_between_check-hours': '0',
|
||||
'requests-time_between_check-minutes': '5',
|
||||
'requests-time_between_check-seconds': '0',
|
||||
'requests-time_between_check-weeks': '0',
|
||||
'requests-workers': '10',
|
||||
'requests-timeout': '60',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
# Form re-renders with the validation error — page itself returns 200.
|
||||
assert res.status_code == 200
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
assert 'ALLOW_IANA_RESTRICTED_ADDRESSES' in body, \
|
||||
"Settings page should surface the SSRF guard's bypass-env-var hint"
|
||||
|
||||
saved = ds.data['settings']['application'].get('llm', {}).get('api_base', '')
|
||||
assert saved != 'http://127.0.0.1:11434', \
|
||||
f"Private api_base must not have been persisted (got {saved!r})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential exfiltration — stored api_key must NOT be auto-substituted when
|
||||
# the caller points api_base at a different (potentially attacker-controlled)
|
||||
# endpoint. GHSA-g36r-fm2p-87xm.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_llm_models_refuses_to_leak_stored_key_to_different_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""If the request supplies an api_base that differs from the saved one but
|
||||
omits api_key, the endpoint must refuse — otherwise CSRF can ship the
|
||||
stored Authorization: Bearer <key> to an attacker-controlled URL."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds) # stores CANARY_KEY, leaves api_base unset
|
||||
|
||||
# Patch litellm.get_valid_models so that if the guard ever lets us through
|
||||
# we'd see it called — and we can assert it wasn't.
|
||||
import litellm
|
||||
calls = []
|
||||
monkeypatch.setattr(litellm, 'get_valid_models',
|
||||
lambda **kwargs: calls.append(kwargs) or [])
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={
|
||||
'provider': 'openai',
|
||||
'api_base': 'https://attacker.example/v1',
|
||||
# api_key intentionally omitted — this is the CSRF case
|
||||
},
|
||||
)
|
||||
assert res.status_code == 400, \
|
||||
"Endpoint should refuse to substitute stored key to a mismatched api_base"
|
||||
body = res.get_json()
|
||||
assert 'api_key' in body['error'], \
|
||||
f"Error should call out that api_key is required: {body['error']!r}"
|
||||
assert calls == [], "litellm must not have been invoked at all"
|
||||
|
||||
|
||||
def test_llm_test_refuses_to_leak_stored_key_to_different_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""Same guard on /settings/llm/test — attacker-supplied api_base + missing
|
||||
api_key must not result in the stored key being sent to that URL."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds) # stores CANARY_KEY, no stored api_base
|
||||
|
||||
calls = []
|
||||
# Patch the completion wrapper so we'd notice if litellm were invoked.
|
||||
import changedetectionio.llm.client as llm_client
|
||||
monkeypatch.setattr(llm_client, 'completion',
|
||||
lambda **kw: calls.append(kw) or ('', 0, 0, 0))
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_test'),
|
||||
query_string={
|
||||
'model': 'gpt-4o-mini',
|
||||
'api_base': 'https://attacker.example/v1',
|
||||
# api_key intentionally omitted
|
||||
},
|
||||
)
|
||||
assert res.status_code == 400
|
||||
body = res.get_json()
|
||||
assert body['ok'] is False
|
||||
assert 'api_key' in body['error']
|
||||
assert calls == [], "completion() must not have been invoked"
|
||||
|
||||
|
||||
def test_llm_models_allows_stored_key_when_api_base_matches_saved(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""Regression: the legit UI flow (test saved config without retyping the key)
|
||||
must still work — i.e. when request api_base matches the stored api_base,
|
||||
the stored key IS substituted."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true') # so localhost passes SSRF
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
ds.data['settings']['application']['llm']['api_base'] = 'http://localhost:11434'
|
||||
|
||||
received = []
|
||||
import litellm
|
||||
monkeypatch.setattr(litellm, 'get_valid_models',
|
||||
lambda **kwargs: (received.append(kwargs), ['llama3.2'])[1])
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={
|
||||
'provider': 'openai_compatible',
|
||||
'api_base': 'http://localhost:11434', # matches saved
|
||||
# api_key omitted — should fall back to stored CANARY_KEY
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.get_json()
|
||||
assert received and received[0].get('api_key') == CANARY_KEY, \
|
||||
"When api_base matches saved, the stored api_key should be used"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSRF — /clear and /clear-summary-cache must not mutate state on GET
|
||||
# (GHSA-g36r-fm2p-87xm). The <img src=...> CSRF vector relies on GET firing the
|
||||
# mutation; the production guard is "POST only + Flask-WTF CSRF token". The
|
||||
# test config disables WTF_CSRF_ENABLED, so we verify the GET vector by
|
||||
# asserting the mutation didn't happen, and verify POST routing by exercising
|
||||
# the legit confirm-then-POST flow.
|
||||
#
|
||||
# NB: the app registers a catch-all '/<path:filename>' static route, which
|
||||
# intercepts any GET that isn't claimed by a method-matching rule and returns
|
||||
# 404 — so we can't simply assert on status code. The behaviour test below is
|
||||
# the actual security property.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_llm_clear_get_does_not_wipe_config(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""The CSRF surface is GET → mutation. After this fix the endpoint is
|
||||
POST-only, so a GET must leave LLM config intact."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
assert ds.data['settings']['application'].get('llm', {}).get('api_key') == CANARY_KEY
|
||||
|
||||
client.get(url_for('settings.llm.llm_clear'))
|
||||
|
||||
# Mutation must not have happened — that's what defeats <img src=...> CSRF.
|
||||
assert ds.data['settings']['application'].get('llm', {}).get('api_key') == CANARY_KEY, \
|
||||
"GET /settings/llm/clear must not wipe LLM config (CSRF guard)"
|
||||
|
||||
|
||||
def test_llm_clear_summary_cache_get_does_not_wipe_cache(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Same property for the cache wipe endpoint — GET must not delete the
|
||||
change-summary-*.txt files the endpoint targets. To exercise the actual
|
||||
deletion path we have to create a real watch (so a real data_dir exists)
|
||||
and drop a real change-summary-*.txt inside it. POST should remove it;
|
||||
GET must not."""
|
||||
import os
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
|
||||
# Create a real watch — required to exercise llm_clear_summary_cache's
|
||||
# iteration over datastore.data['watching'].values().
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
res = client.post(
|
||||
'/api/v1/watch',
|
||||
data=json.dumps({'url': test_url}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_token},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
uuid = res.json.get('uuid')
|
||||
|
||||
watch = ds.data['watching'][uuid]
|
||||
data_dir = watch.data_dir
|
||||
assert data_dir, "Watch must have a data_dir for this test to be meaningful"
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
summary_file = os.path.join(data_dir, 'change-summary-csrf-canary.txt')
|
||||
with open(summary_file, 'w') as f:
|
||||
f.write('do-not-delete-via-GET')
|
||||
|
||||
# GET must NOT trigger the wipe — this is the CSRF surface that was open
|
||||
# via <img src="/settings/llm/clear-summary-cache">.
|
||||
client.get(url_for('settings.llm.llm_clear_summary_cache'))
|
||||
assert os.path.exists(summary_file), \
|
||||
"GET on /settings/llm/clear-summary-cache must not invoke the cache wipe"
|
||||
|
||||
# Sanity check: POST does remove it — confirms our test actually exercises
|
||||
# the deletion path the GET test is guarding against.
|
||||
client.post(url_for('settings.llm.llm_clear_summary_cache'))
|
||||
assert not os.path.exists(summary_file), \
|
||||
"POST on /settings/llm/clear-summary-cache should remove change-summary-*.txt"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_llm_clear_via_post_still_works(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Confirm the legit confirm-then-POST flow wipes the provider credentials.
|
||||
|
||||
Post-LLMSettings: /llm/clear strips only the connection fields (model, api_key,
|
||||
api_base, provider_kind, local_token_multiplier). User-set toggles, the global
|
||||
summary prompt, monthly budgets, and system token counters survive. This matches
|
||||
the settings-page "empty model" save semantic and the LLMSettings.CONNECTION_FIELDS
|
||||
grouping — see PYDANTIC_MIGRATION.md.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
assert ds.data['settings']['application'].get('llm', {}).get('api_key') == CANARY_KEY
|
||||
|
||||
res = client.post(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
# The api_key must be gone (this is what the test really cares about).
|
||||
llm = ds.data['settings']['application'].get('llm') or {}
|
||||
assert 'api_key' not in llm, f"api_key should have been wiped, got: {llm!r}"
|
||||
assert 'model' not in llm
|
||||
assert 'api_base' not in llm
|
||||
|
||||
@@ -28,11 +28,7 @@ def _set_response(datastore_path, content):
|
||||
|
||||
def _configure_llm(client):
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
# Merge into the existing llm dict so other test setup (e.g. change_summary_default
|
||||
# set via _set_global_default) survives.
|
||||
existing = ds.data['settings']['application'].get('llm') or {}
|
||||
existing.update({'model': 'gpt-4o-mini', 'api_key': 'sk-test'})
|
||||
ds.data['settings']['application']['llm'] = existing
|
||||
ds.data['settings']['application']['llm'] = {'model': 'gpt-4o-mini', 'api_key': 'sk-test'}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -242,9 +238,7 @@ def test_llm_summary_ajax_error_displayed_not_silenced(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _set_global_default(ds, prompt):
|
||||
llm = ds.data['settings']['application'].get('llm') or {}
|
||||
llm['change_summary_default'] = prompt
|
||||
ds.data['settings']['application']['llm'] = llm
|
||||
ds.data['settings']['application']['llm_change_summary_default'] = prompt
|
||||
|
||||
|
||||
def test_global_default_used_when_watch_and_tag_have_none(
|
||||
@@ -335,7 +329,7 @@ def test_hardcoded_fallback_when_nothing_set(
|
||||
watch['llm_change_summary'] = ''
|
||||
|
||||
# Ensure global default is also empty
|
||||
_set_global_default(ds, '')
|
||||
ds.data['settings']['application']['llm_change_summary_default'] = ''
|
||||
|
||||
assert get_effective_summary_prompt(watch, ds) == DEFAULT_CHANGE_SUMMARY_PROMPT
|
||||
|
||||
@@ -397,8 +391,8 @@ def test_llm_summary_ajax_sets_last_viewed(
|
||||
def test_global_default_saved_and_loaded_via_settings_form(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Submitting the settings form persists the global default prompt into
|
||||
application.llm.change_summary_default (single nested home for all LLM settings).
|
||||
Submitting the settings form persists llm_change_summary_default at
|
||||
settings.application level (not inside the llm credentials dict).
|
||||
"""
|
||||
from changedetectionio.tests.util import live_server_setup
|
||||
live_server_setup(live_server)
|
||||
@@ -411,20 +405,21 @@ def test_global_default_saved_and_loaded_via_settings_form(
|
||||
'application-empty_pages_are_a_change': '',
|
||||
'requests-time_between_check-minutes': 180,
|
||||
'application-fetch_backend': 'html_requests',
|
||||
'llm-change_summary_default': 'Saved global prompt.',
|
||||
'llm-llm_change_summary_default': 'Saved global prompt.',
|
||||
# Keep existing model so llm block is retained
|
||||
'llm-model': 'gpt-4o-mini',
|
||||
'llm-llm_model': 'gpt-4o-mini',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert b'Settings updated.' in res.data
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
llm_dict = ds.data['settings']['application'].get('llm', {})
|
||||
assert llm_dict.get('change_summary_default') == 'Saved global prompt.', f"Got: {llm_dict!r}"
|
||||
stored = ds.data['settings']['application'].get('llm_change_summary_default', '')
|
||||
assert stored == 'Saved global prompt.', f"Got: {stored!r}"
|
||||
|
||||
# And the old flat key must not be re-introduced
|
||||
assert 'llm_change_summary_default' not in ds.data['settings']['application']
|
||||
# Must NOT be buried inside the llm credentials dict
|
||||
llm_dict = ds.data['settings']['application'].get('llm', {})
|
||||
assert 'change_summary_default' not in llm_dict
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
@@ -442,14 +437,10 @@ def test_global_default_survives_llm_clear(
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_set_global_default(ds, 'Surviving prompt.')
|
||||
|
||||
res = client.post(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
res = client.get(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
llm_dict = ds.data['settings']['application'].get('llm') or {}
|
||||
assert llm_dict.get('change_summary_default') == 'Surviving prompt.'
|
||||
# The credential fields should be gone
|
||||
assert 'model' not in llm_dict
|
||||
assert 'api_key' not in llm_dict
|
||||
assert ds.data['settings']['application'].get('llm_change_summary_default') == 'Surviving prompt.'
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
@@ -168,9 +168,9 @@ def test_settings_form_preserves_token_counters(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
# LLM sub-form fields
|
||||
'llm-model': 'gpt-4o',
|
||||
'llm-api_key': 'sk-different-key',
|
||||
'llm-api_base': '',
|
||||
'llm-llm_model': 'gpt-4o',
|
||||
'llm-llm_api_key': 'sk-different-key',
|
||||
'llm-llm_api_base': '',
|
||||
# Minimal required fields to pass form validation
|
||||
'application-pager_size': '50',
|
||||
'application-notification_format': 'System default',
|
||||
@@ -196,81 +196,6 @@ def test_settings_form_preserves_token_counters(
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_settings_form_blank_llm_integer_fields_preserve_stored_values(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Empty IntegerField submissions come back as None from WTForms. LLMSettings
|
||||
declares token_budget_month / max_input_chars / max_tokens_per_count_period /
|
||||
local_token_multiplier as strict `int`, so a None passed through to
|
||||
model_validate raises ValidationError and 500s the settings save.
|
||||
|
||||
Regression for settings/__init__.py — the LLM merge must drop None values
|
||||
(treat them like absent keys) so blank IntegerField submissions preserve
|
||||
the stored value instead of crashing the form.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
ds.data['settings']['application']['llm'] = {
|
||||
'model': 'gpt-4o',
|
||||
'api_key': 'sk-existing',
|
||||
'token_budget_month': 50000,
|
||||
'max_input_chars': 200000,
|
||||
'max_tokens_per_count_period': 1000,
|
||||
'local_token_multiplier': 3,
|
||||
}
|
||||
|
||||
res = client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-model': 'gpt-4o',
|
||||
'llm-api_key': '',
|
||||
'llm-api_base': '',
|
||||
# The bug-trigger: every LLM IntegerField submitted blank
|
||||
'llm-token_budget_month': '',
|
||||
'llm-max_input_chars': '',
|
||||
'llm-max_tokens_per_count_period': '',
|
||||
'llm-local_token_multiplier': '',
|
||||
# Minimal required fields for the rest of the form to validate.
|
||||
# 'System default' is popped from notification_format choices for the
|
||||
# global form, so it must be one of the real codes (e.g. 'html').
|
||||
'application-pager_size': '50',
|
||||
'application-notification_format': 'html',
|
||||
'application-fetch_backend': 'html_requests',
|
||||
'application-rss_diff_length': '5',
|
||||
'application-filter_failure_notification_threshold_attempts': '0',
|
||||
'requests-time_between_check-days': '0',
|
||||
'requests-time_between_check-hours': '0',
|
||||
'requests-time_between_check-minutes': '5',
|
||||
'requests-time_between_check-seconds': '0',
|
||||
'requests-time_between_check-weeks': '0',
|
||||
'requests-jitter_seconds': '0',
|
||||
'requests-workers': '10',
|
||||
'requests-timeout': '60',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 200, \
|
||||
f"Settings save crashed on blank LLM IntegerField submission (got {res.status_code})"
|
||||
# Sanity: the form must have actually validated and reached the LLM save path
|
||||
# — without this the test would trivially pass because the buggy code never ran.
|
||||
assert b'Settings updated.' in res.data, \
|
||||
"Settings form did not validate — the bug-path was never exercised. Check fixture fields."
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
assert 'ValidationError' not in body, \
|
||||
"Pydantic ValidationError leaked into the response — blank IntegerField wasn't filtered"
|
||||
|
||||
llm = ds.data['settings']['application'].get('llm') or {}
|
||||
assert llm.get('token_budget_month') == 50000, \
|
||||
f"Blank submission must preserve stored token_budget_month (got {llm.get('token_budget_month')!r})"
|
||||
assert llm.get('max_input_chars') == 200000, \
|
||||
f"Blank submission must preserve stored max_input_chars (got {llm.get('max_input_chars')!r})"
|
||||
assert llm.get('max_tokens_per_count_period') == 1000, \
|
||||
f"Blank submission must preserve stored max_tokens_per_count_period (got {llm.get('max_tokens_per_count_period')!r})"
|
||||
assert llm.get('local_token_multiplier') == 3, \
|
||||
f"Blank submission must preserve stored local_token_multiplier (got {llm.get('local_token_multiplier')!r})"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_settings_form_cannot_inject_fake_token_counts(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
@@ -284,9 +209,9 @@ def test_settings_form_cannot_inject_fake_token_counts(
|
||||
res = client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-model': 'gpt-4o-mini',
|
||||
'llm-api_key': 'sk-test',
|
||||
'llm-api_base': '',
|
||||
'llm-llm_model': 'gpt-4o-mini',
|
||||
'llm-llm_api_key': 'sk-test',
|
||||
'llm-llm_api_base': '',
|
||||
# Attempted injection of token counter fields
|
||||
'llm-tokens_this_month': '0',
|
||||
'llm-tokens_total_cumulative': '0',
|
||||
@@ -546,9 +471,9 @@ def test_cost_fields_are_tamper_proof_via_settings_form(
|
||||
client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-model': 'gpt-4o',
|
||||
'llm-api_key': 'sk-test',
|
||||
'llm-api_base': '',
|
||||
'llm-llm_model': 'gpt-4o',
|
||||
'llm-llm_api_key': 'sk-test',
|
||||
'llm-llm_api_base': '',
|
||||
'llm-cost_usd_this_month': '0', # injection attempt
|
||||
'llm-cost_usd_total_cumulative': '0', # injection attempt
|
||||
'application-pager_size': '50',
|
||||
|
||||
@@ -541,39 +541,6 @@ def test_single_send_test_notification_on_watch(client, live_server, measure_mem
|
||||
assert 'Current snapshot: Example text: example test' in x
|
||||
os.unlink(os.path.join(datastore_path, "notification.txt"))
|
||||
|
||||
# Regression test for #4119 - sending a test notification with 'System default' format caused a crash
|
||||
def test_send_test_notification_with_system_default_format(client, live_server, measure_memory_usage, datastore_path):
|
||||
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
|
||||
os.unlink(os.path.join(datastore_path, "notification.txt"))
|
||||
|
||||
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?status_code=204"
|
||||
|
||||
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)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# New watches default to USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH.
|
||||
# The JS sends this value verbatim from the select; it must not crash.
|
||||
res = client.post(
|
||||
url_for("ui.ui_notification.ajax_callback_send_notification_test") + f"/{uuid}",
|
||||
data={
|
||||
"notification_urls": test_notification_url,
|
||||
"notification_body": default_notification_body,
|
||||
"notification_title": default_notification_title,
|
||||
"notification_format": USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH,
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
assert res.status_code != 400
|
||||
assert res.status_code != 500
|
||||
|
||||
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
|
||||
|
||||
|
||||
def _test_color_notifications(client, notification_body_token, datastore_path):
|
||||
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
@@ -634,241 +601,4 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
|
||||
def test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path):
|
||||
_test_color_notifications(client, '{{diff}}',datastore_path=datastore_path)
|
||||
_test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path)
|
||||
# Regression: the html-output escape pass in handler.py used to convert
|
||||
# FormattableDiff into a plain str, stripping its __call__ and breaking any
|
||||
# {{ diff(...) }} / {{ diff_added(...) }} token on htmlcolor/html notifications
|
||||
# with 'str' object is not callable (see commit 08d30c6 + #3923).
|
||||
# word_diff=false reproduces the exact form the user-reported failure used.
|
||||
_test_color_notifications(client, '{{diff(word_diff=false)}}', datastore_path=datastore_path)
|
||||
|
||||
|
||||
def _test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type=None):
|
||||
"""
|
||||
#4121 - The operator's own HTML in the notification body template (e.g.
|
||||
<a href="{{watch_url}}">) must survive unescaped regardless of the watched page's
|
||||
Content-Type. The escape pass in handler.py only touches the variable *values*
|
||||
(diff/snapshot content from the page — see GHSA-q8xq-qg4x-wphg) — it leaves the
|
||||
surrounding template HTML alone.
|
||||
"""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
|
||||
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
|
||||
os.unlink(os.path.join(datastore_path, "notification.txt"))
|
||||
|
||||
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')
|
||||
|
||||
kwargs = {'content_type': content_type} if content_type else {}
|
||||
test_url = url_for('test_endpoint', _external=True, **kwargs)
|
||||
|
||||
res = client.post(
|
||||
url_for("settings.settings_page"),
|
||||
data={
|
||||
"application-fetch_backend": "html_requests",
|
||||
"application-minutes_between_check": 180,
|
||||
"application-notification_body": '<a href="{{watch_url}}">Watch Link</a> had changes\n\n{{diff}}',
|
||||
"application-notification_format": "htmlcolor",
|
||||
"application-notification_urls": test_notification_url,
|
||||
"application-notification_title": "Change detected",
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b'Settings updated' in res.data
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_views.form_quick_watch_add"),
|
||||
data={"url": test_url, "tags": ''},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b"Watch added" in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
set_modified_response(datastore_path=datastore_path)
|
||||
|
||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
assert b'Queued 1 watch for rechecking.' in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
||||
|
||||
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
|
||||
x = f.read()
|
||||
|
||||
assert '<a href=' not in x, f"Custom HTML <a> tag was incorrectly escaped (content_type={content_type})"
|
||||
assert '<a href=' in x, f"Custom HTML <a> tag not found unescaped (content_type={content_type})"
|
||||
assert '<span' in x, f"Expected color <span> tags not found (content_type={content_type})"
|
||||
|
||||
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
|
||||
|
||||
|
||||
def test_plaintext_watch_custom_html_in_notification_body_not_escaped(client, live_server, measure_memory_usage, datastore_path):
|
||||
# Diff/snapshot values are escaped for HTML notifications (covered by
|
||||
# test_html_watch_diff_content_escaped_in_html_notification). What this test
|
||||
# locks in is that the *surrounding* template HTML is left alone in every case.
|
||||
_test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type="text/plain")
|
||||
_test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type="text/html")
|
||||
_test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type=None)
|
||||
|
||||
|
||||
def test_html_watch_diff_content_escaped_in_html_notification(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
GHSA-q8xq-qg4x-wphg — diff/snapshot content from the watched page must be
|
||||
HTML-escaped before it is rendered into an HTML-format notification, regardless
|
||||
of the watched page's Content-Type.
|
||||
|
||||
Inscriptis (used to convert text/html pages to snapshot text) decodes HTML
|
||||
entities — so a page that visibly displays "<a href=...>" produces snapshot
|
||||
text containing literal "<a href=...>". The previous gate at handler.py:391
|
||||
only escaped when watch_mime_type matched 'text/' and not 'html', which let
|
||||
that decoded markup through to HTML emails / Telegram (parse_mode=html) /
|
||||
Discord embeds, where it renders as a real clickable link — i.e. an attacker
|
||||
who controls a watched page can inject phishing links into the operator's
|
||||
trusted notification channel.
|
||||
"""
|
||||
from .util import write_test_file_and_sync
|
||||
|
||||
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
|
||||
os.unlink(os.path.join(datastore_path, "notification.txt"))
|
||||
|
||||
# Baseline: an innocuous text/html page.
|
||||
baseline_html = "<html><body><p>nothing to see here</p></body></html>"
|
||||
write_test_file_and_sync(os.path.join(datastore_path, "endpoint-content.txt"), baseline_html)
|
||||
|
||||
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')
|
||||
# Pass content_type=text/html so the watch records 'text/html' as its content-type
|
||||
# — this is the branch the previous gate skipped escaping for.
|
||||
test_url = url_for('test_endpoint', _external=True, content_type='text/html')
|
||||
|
||||
# HTML-format notification body that embeds the snapshot directly. Operators do this
|
||||
# when they want the full changed content in the alert (e.g. an email digest).
|
||||
res = client.post(
|
||||
url_for("settings.settings_page"),
|
||||
data={
|
||||
"application-fetch_backend": "html_requests",
|
||||
"application-minutes_between_check": 180,
|
||||
"application-notification_body": 'Watch had changes:\n{{current_snapshot}}',
|
||||
"application-notification_format": "html",
|
||||
"application-notification_urls": test_notification_url,
|
||||
"application-notification_title": "Change detected",
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b'Settings updated' in res.data
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_views.form_quick_watch_add"),
|
||||
data={"url": test_url, "tags": ''},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b"Watch added" in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Now flip the page to something whose *visible* text contains entity-encoded
|
||||
# angle brackets — exactly the pattern a forum / pastebin / code-sample site uses
|
||||
# to display literal HTML on the page. Inscriptis will decode </> back to
|
||||
# literal < / > in the stored snapshot.
|
||||
attacker_html = (
|
||||
'<html><body><pre>'
|
||||
'<a href="https://attacker.example/payment">ACTION REQUIRED</a>'
|
||||
'<img src="https://attacker.example/track" width="1" height="1">'
|
||||
'</pre></body></html>'
|
||||
)
|
||||
write_test_file_and_sync(os.path.join(datastore_path, "endpoint-content.txt"), attacker_html)
|
||||
|
||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
assert b'Queued 1 watch for rechecking.' in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
||||
|
||||
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
|
||||
body = f.read()
|
||||
|
||||
# Sanity: the snapshot really did contain the decoded markup (otherwise the test
|
||||
# would pass for the wrong reason). The escaped form must appear somewhere.
|
||||
assert '<a href=' in body or '&lt;a href=' in body, \
|
||||
f"Expected escaped attacker markup in notification body, got: {body!r}"
|
||||
|
||||
# The bug: a live <a href="https://attacker..."> ends up in the HTML notification.
|
||||
assert '<a href="https://attacker.example/payment"' not in body, \
|
||||
f"Diff content from text/html page was NOT escaped — phishing link reached HTML notification: {body!r}"
|
||||
assert '<img src="https://attacker.example/track"' not in body, \
|
||||
f"Diff content from text/html page was NOT escaped — tracking pixel reached HTML notification: {body!r}"
|
||||
|
||||
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
|
||||
|
||||
|
||||
def test_source_url_diff_content_escaped_in_html_notification(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
GHSA-q8xq-qg4x-wphg — companion to the inscriptis test. `source:`-prefixed
|
||||
URLs short-circuit the HTML→text step (processor.py:509-511) and store the
|
||||
raw HTML body verbatim as the snapshot. That gives an attacker who controls
|
||||
a watched page a *direct* injection path — no entity-encoding tricks needed,
|
||||
any live `<a>` / `<img>` / `<script>` on the page lands straight into
|
||||
current_snapshot / raw_diff. The escape pass must catch this too.
|
||||
"""
|
||||
from .util import write_test_file_and_sync
|
||||
|
||||
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
|
||||
os.unlink(os.path.join(datastore_path, "notification.txt"))
|
||||
|
||||
# Baseline: innocuous raw HTML.
|
||||
baseline_html = "<html><body><p>nothing to see here</p></body></html>"
|
||||
write_test_file_and_sync(os.path.join(datastore_path, "endpoint-content.txt"), baseline_html)
|
||||
|
||||
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')
|
||||
# `source:` prefix → raw HTML body is stored as-is in the snapshot (no inscriptis).
|
||||
test_url = 'source:' + url_for('test_endpoint', _external=True, content_type='text/html')
|
||||
|
||||
res = client.post(
|
||||
url_for("settings.settings_page"),
|
||||
data={
|
||||
"application-fetch_backend": "html_requests",
|
||||
"application-minutes_between_check": 180,
|
||||
"application-notification_body": 'Watch had changes:\n{{current_snapshot}}',
|
||||
"application-notification_format": "html",
|
||||
"application-notification_urls": test_notification_url,
|
||||
"application-notification_title": "Change detected",
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b'Settings updated' in res.data
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_views.form_quick_watch_add"),
|
||||
data={"url": test_url, "tags": ''},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b"Watch added" in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Modified page contains LIVE HTML directly — no entity encoding. With source:
|
||||
# this lands in the snapshot verbatim.
|
||||
attacker_html = (
|
||||
'<html><body>'
|
||||
'<a href="https://attacker.example/payment">ACTION REQUIRED</a>'
|
||||
'<img src="https://attacker.example/track" width="1" height="1">'
|
||||
'</body></html>'
|
||||
)
|
||||
write_test_file_and_sync(os.path.join(datastore_path, "endpoint-content.txt"), attacker_html)
|
||||
|
||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
assert b'Queued 1 watch for rechecking.' in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
||||
|
||||
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
|
||||
body = f.read()
|
||||
|
||||
# Sanity: snapshot really did carry the markup through. Escaped form must show up.
|
||||
assert '<a href=' in body or '&lt;a href=' in body, \
|
||||
f"Expected escaped attacker markup in notification body, got: {body!r}"
|
||||
|
||||
assert '<a href="https://attacker.example/payment"' not in body, \
|
||||
f"source: URL raw HTML was NOT escaped — phishing link reached HTML notification: {body!r}"
|
||||
assert '<img src="https://attacker.example/track"' not in body, \
|
||||
f"source: URL raw HTML was NOT escaped — tracking pixel reached HTML notification: {body!r}"
|
||||
|
||||
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
|
||||
|
||||
@@ -285,7 +285,7 @@ def test_itemprop_percent_threshold(client, live_server, measure_memory_usage, d
|
||||
client.get(url_for("ui.form_watch_checknow"))
|
||||
wait_for_all_checks(client)
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'1,960.45' in res.data or b'1960.45' in res.data #depending on locale
|
||||
assert b'1,960.45' or b'1960.45' in res.data #depending on locale
|
||||
assert b'has-unread-changes' in res.data
|
||||
|
||||
|
||||
@@ -295,28 +295,7 @@ def test_itemprop_percent_threshold(client, live_server, measure_memory_usage, d
|
||||
client.get(url_for("ui.form_watch_checknow"))
|
||||
wait_for_all_checks(client)
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'1,950.45' in res.data or b'1950.45' in res.data #depending on locale
|
||||
assert b'has-unread-changes' not in res.data
|
||||
|
||||
# PROOF that the threshold is measured "since the PREVIOUS check" and NOT "since the first check":
|
||||
# a slow upward creep where every single step is below the 5% threshold versus the *previous*
|
||||
# check, but the total drift from where the creep started (1950.45) ends up ABOVE 5%.
|
||||
# 1950.45 -> 2000.00 = +2.54% vs previous (below 5%)
|
||||
# 2000.00 -> 2050.00 = +2.50% vs previous (below 5%)
|
||||
# 2050.00 -> 2100.00 = +2.44% vs previous (below 5%)
|
||||
# 1950.45 -> 2100.00 = +7.67% in total (ABOVE 5%)
|
||||
# Under "since previous check" NONE of these trigger (each step is sub-threshold).
|
||||
# Under "since first check" the accumulated drift would cross 5% and trigger here - so the
|
||||
# final assertion below would fail. We deliberately never mark_all_viewed during the creep,
|
||||
# so any single trigger would leave has-unread-changes set.
|
||||
for creep_price in ['2000.00', '2050.00', '2100.00']:
|
||||
set_original_response(props_markup=instock_props[0], price=creep_price, datastore_path=datastore_path)
|
||||
client.get(url_for("ui.form_watch_checknow"))
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'2,100.00' in res.data or b'2100.00' in res.data #depending on locale
|
||||
# +7.67% total drift since the creep started, yet still unread-free -> comparison is vs PREVIOUS check
|
||||
assert b'1,950.45' or b'1950.45' in res.data #depending on locale
|
||||
assert b'has-unread-changes' not in res.data
|
||||
|
||||
|
||||
@@ -370,9 +349,9 @@ def test_change_with_notification_values(client, live_server, measure_memory_usa
|
||||
# Should see new tokens register
|
||||
res = client.get(url_for("settings.settings_page"))
|
||||
|
||||
assert b'{{restock.last_price}}' in res.data
|
||||
assert b'{{restock.original_price}}' in res.data
|
||||
assert b'{{restock.previous_price}}' in res.data
|
||||
assert b'Price at the previous check' in res.data
|
||||
assert b'Original price at first check' in res.data
|
||||
|
||||
#####################
|
||||
# Set this up for when we remove the notification from the watch, it should fallback with these details
|
||||
|
||||
@@ -760,9 +760,7 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
||||
|
||||
f = RequestsFetcher()
|
||||
|
||||
# Patch the underlying is_private_hostname in validate_url — the fetcher now goes through
|
||||
# is_url_private_or_parser_confused() (GHSA-rph4-96w6-q594), which calls it transitively.
|
||||
with patch('changedetectionio.validate_url.is_private_hostname', return_value=True):
|
||||
with patch('changedetectionio.content_fetchers.requests.is_private_hostname', return_value=True):
|
||||
with pytest.raises(Exception, match='private/reserved'):
|
||||
f._run_sync(
|
||||
url='http://example.com/',
|
||||
@@ -786,7 +784,7 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
||||
return hostname in {'169.254.169.254', '10.0.0.1', '172.16.0.1',
|
||||
'192.168.0.1', '127.0.0.1', '::1'}
|
||||
|
||||
with patch('changedetectionio.validate_url.is_private_hostname',
|
||||
with patch('changedetectionio.content_fetchers.requests.is_private_hostname',
|
||||
side_effect=_private_only_for_redirect):
|
||||
with patch('requests.Session.request', return_value=mock_redirect):
|
||||
with pytest.raises(Exception, match='Redirect blocked'):
|
||||
@@ -831,113 +829,6 @@ def test_unresolvable_hostname_is_allowed(client, live_server, monkeypatch):
|
||||
"Unresolvable hostname watch should appear in the watch overview list"
|
||||
|
||||
|
||||
def test_ghsa_rph4_96w6_q594_urlparse_urllib3_parser_differential_ssrf(client, live_server, monkeypatch, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
GHSA-rph4-96w6-q594: SSRF via urlparse/urllib3 parser differential.
|
||||
|
||||
A URL like http://INTERNAL:8888\\@PUBLIC/ is parsed two different ways:
|
||||
- urlparse() treats \\@ as a credential separator → hostname = PUBLIC
|
||||
- urllib3 treats \\ as a path character → hostname = INTERNAL
|
||||
The pre-fetch SSRF check used urlparse(), but requests/urllib3 actually connected
|
||||
to INTERNAL. Fix: parser-agnostic gate that (a) blocks any URL containing a
|
||||
backslash and (b) validates every hostname both parsers produce.
|
||||
|
||||
Covers:
|
||||
1. extract_url_hostnames() reveals BOTH hostnames for the payload
|
||||
2. is_url_private_or_parser_confused() blocks backslash payloads outright
|
||||
3. is_safe_valid_url() rejects backslash payloads at add-time
|
||||
4. The /api/v1/watch add endpoint rejects the payload
|
||||
5. The requests fetcher refuses the payload at fetch-time
|
||||
6. The redirect-following loop refuses a backslash payload in Location
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from changedetectionio.validate_url import (
|
||||
extract_url_hostnames,
|
||||
is_safe_valid_url,
|
||||
is_url_private_or_parser_confused,
|
||||
)
|
||||
|
||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
|
||||
|
||||
# The published proof-of-concept payload — backslash splits the two parsers' views.
|
||||
payload = "http://169.254.169.254:8888" + chr(92) + "@httpbin.org/latest/meta-data/"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. extract_url_hostnames() returns BOTH parsers' hostnames
|
||||
# ---------------------------------------------------------------
|
||||
hosts = extract_url_hostnames(payload)
|
||||
assert '169.254.169.254' in hosts, \
|
||||
f"urllib3 sees 169.254.169.254 as the connect target; extract_url_hostnames must surface it. Got {hosts!r}"
|
||||
assert 'httpbin.org' in hosts, \
|
||||
f"urlparse sees httpbin.org; extract_url_hostnames must surface it too. Got {hosts!r}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. Parser-agnostic gate blocks the payload
|
||||
# ---------------------------------------------------------------
|
||||
assert is_url_private_or_parser_confused(payload), \
|
||||
"Parser-differential payload must be blocked by the SSRF gate"
|
||||
|
||||
# And a plain backslash anywhere in the URL is enough to block, even without a private IP
|
||||
assert is_url_private_or_parser_confused("http://example.com" + chr(92) + "@evil.com/"), \
|
||||
"Any backslash in a URL must trigger the parser-differential block"
|
||||
|
||||
# Sanity: a regular public URL is not blocked
|
||||
assert not is_url_private_or_parser_confused("http://example.com/path"), \
|
||||
"Plain public URLs must continue to pass the gate"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. is_safe_valid_url() rejects backslash payloads at add-time
|
||||
# ---------------------------------------------------------------
|
||||
assert not is_safe_valid_url(payload), \
|
||||
"is_safe_valid_url must reject URLs containing a backslash (parser-differential vector)"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. The watch-add API endpoint rejects the payload
|
||||
# ---------------------------------------------------------------
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
res = client.post(
|
||||
url_for('createwatch'),
|
||||
data='{"url": "%s", "fetch_backend": "html_requests"}' % payload,
|
||||
headers={'x-api-key': api_key, 'Content-Type': 'application/json'},
|
||||
)
|
||||
assert res.status_code >= 400, \
|
||||
f"API must refuse to create a watch for parser-differential URL; got status {res.status_code} body {res.data!r}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 5. Requests fetcher refuses the payload at fetch-time
|
||||
# ---------------------------------------------------------------
|
||||
from changedetectionio.content_fetchers.requests import fetcher as RequestsFetcher
|
||||
|
||||
f = RequestsFetcher()
|
||||
with pytest.raises(Exception, match='private/reserved|parser-differential'):
|
||||
f._run_sync(
|
||||
url=payload,
|
||||
timeout=5,
|
||||
request_headers={},
|
||||
request_body=None,
|
||||
request_method='GET',
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 6. A 302 Location header pointing at a backslash payload is blocked
|
||||
# (open-redirect → SSRF via parser differential)
|
||||
# ---------------------------------------------------------------
|
||||
mock_redirect = MagicMock()
|
||||
mock_redirect.is_redirect = True
|
||||
mock_redirect.status_code = 302
|
||||
mock_redirect.headers = {'Location': payload}
|
||||
|
||||
with patch('requests.Session.request', return_value=mock_redirect):
|
||||
with pytest.raises(Exception, match='Redirect blocked'):
|
||||
f._run_sync(
|
||||
url='http://example.com/',
|
||||
timeout=5,
|
||||
request_headers={},
|
||||
request_body=None,
|
||||
request_method='GET',
|
||||
)
|
||||
|
||||
|
||||
def test_ghsa_8757_69j2_hx56_backup_restore_history_path_traversal(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
GHSA-8757-69j2-hx56: Crafted backup ZIP with absolute path in history.txt must not
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
|
||||
"""Unit tests for html_tools.extract_title — including regression for #4217.
|
||||
|
||||
Issue #4217: extract_title silently returns None for pages where <title> is
|
||||
pushed past the hard-coded 8 192-character scan window by large <head> content
|
||||
(e.g. Amazon product pages where <title> can sit at character index 55 000+).
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from changedetectionio.html_tools import extract_title
|
||||
|
||||
|
||||
def _make_large_head_page(title: str, filler_count: int = 500) -> bytes:
|
||||
"""Build a synthetic HTML page whose <title> is pushed far past 8 192 chars.
|
||||
|
||||
Each filler line is ~126 bytes; 500 lines ≈ 63 000 bytes before <title>.
|
||||
"""
|
||||
filler_line = '<meta name="x" content="' + "A" * 100 + '"/>\n'
|
||||
head_junk = filler_line * filler_count
|
||||
page = (
|
||||
f"<html><head>{head_junk}"
|
||||
f"<title>{title}</title>"
|
||||
f"</head><body></body></html>"
|
||||
)
|
||||
return page.encode("utf-8")
|
||||
|
||||
|
||||
class TestExtractTitle(unittest.TestCase):
|
||||
# ------------------------------------------------------------------
|
||||
# Regression: issue #4217 — large <head> pushes <title> past scan limit
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_large_head_bytes_title_extracted(self):
|
||||
"""<title> beyond 8 192 bytes must still be extracted (bytes input)."""
|
||||
page = _make_large_head_page("Amazon Product Title - Real Title Here")
|
||||
title_pos = page.find(b"<title")
|
||||
self.assertGreater(
|
||||
title_pos,
|
||||
8192,
|
||||
f"Precondition: <title> must be past 8 192 chars (actual: {title_pos})",
|
||||
)
|
||||
result = extract_title(page)
|
||||
self.assertEqual(result, "Amazon Product Title - Real Title Here")
|
||||
|
||||
def test_large_head_str_title_extracted(self):
|
||||
"""<title> beyond 8 192 chars must still be extracted (str input)."""
|
||||
page_bytes = _make_large_head_page("Large Head String Test")
|
||||
page_str = page_bytes.decode("utf-8")
|
||||
title_pos = page_str.find("<title")
|
||||
self.assertGreater(title_pos, 8192)
|
||||
result = extract_title(page_str)
|
||||
self.assertEqual(result, "Large Head String Test")
|
||||
|
||||
def test_very_large_head_55000_chars(self):
|
||||
"""Simulate Amazon-like pages where <title> is at ~55 000 chars."""
|
||||
# Use a filler that puts the title at ~55 000 chars
|
||||
filler_line = '<meta name="description" content="' + "B" * 200 + '"/>\n'
|
||||
filler_count = 230 # ~235 bytes * 230 ≈ 54 050 chars before <title>
|
||||
head_junk = filler_line * filler_count
|
||||
page = (
|
||||
f"<html><head>{head_junk}"
|
||||
f"<title>ASIN B0B9CGQ14V - Echo Dot (5th Gen)</title>"
|
||||
f"</head><body>body content</body></html>"
|
||||
).encode("utf-8")
|
||||
title_pos = page.find(b"<title")
|
||||
self.assertGreater(title_pos, 8192, f"<title> at {title_pos}, expected > 8192")
|
||||
result = extract_title(page)
|
||||
self.assertEqual(result, "ASIN B0B9CGQ14V - Echo Dot (5th Gen)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Baseline: small pages must continue to work
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_normal_small_page(self):
|
||||
"""Standard small page should extract title correctly."""
|
||||
page = b"<html><head><title>Simple Page</title></head><body>text</body></html>"
|
||||
self.assertEqual(extract_title(page), "Simple Page")
|
||||
|
||||
def test_str_input_small_page(self):
|
||||
"""str input small page."""
|
||||
page = "<html><head><title>String Input</title></head><body></body></html>"
|
||||
self.assertEqual(extract_title(page), "String Input")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_no_title_tag_returns_none(self):
|
||||
"""No <title> in document → None."""
|
||||
page = b"<html><head></head><body>no title here</body></html>"
|
||||
self.assertIsNone(extract_title(page))
|
||||
|
||||
def test_empty_bytes_returns_none(self):
|
||||
"""Empty bytes → None."""
|
||||
self.assertIsNone(extract_title(b""))
|
||||
|
||||
def test_html_entities_decoded(self):
|
||||
"""HTML entities inside <title> must be decoded."""
|
||||
page = b"<html><head><title>Café & Tea</title></head><body></body></html>"
|
||||
self.assertEqual(extract_title(page), "Café & Tea")
|
||||
|
||||
def test_extra_whitespace_collapsed(self):
|
||||
"""Leading/trailing/internal whitespace in title is collapsed."""
|
||||
page = b"<html><head><title> Multiple Spaces </title></head><body></body></html>"
|
||||
self.assertEqual(extract_title(page), "Multiple Spaces")
|
||||
|
||||
def test_title_with_attributes_on_tag(self):
|
||||
"""<title lang="en"> (tag with attributes) must still match."""
|
||||
page = b'<html><head><title lang="en">Attributed Title</title></head><body></body></html>'
|
||||
self.assertEqual(extract_title(page), "Attributed Title")
|
||||
|
||||
def test_long_title_capped_at_2000_chars(self):
|
||||
"""Titles longer than 2 000 chars are capped."""
|
||||
long_title = "T" * 3000
|
||||
page = f"<html><head><title>{long_title}</title></head><body></body></html>".encode()
|
||||
result = extract_title(page)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(len(result), 2000)
|
||||
|
||||
def test_title_300_chars_preserved(self):
|
||||
"""Titles up to 2 000 chars are preserved in full."""
|
||||
title = "X" * 300
|
||||
page = f"<html><head><title>{title}</title></head><body></body></html>".encode()
|
||||
self.assertEqual(extract_title(page), title)
|
||||
|
||||
def test_unsupported_type_returns_none(self):
|
||||
"""Passing an unsupported type (e.g. int) returns None without raising."""
|
||||
self.assertIsNone(extract_title(12345)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# run from dir above changedetectionio/ dir
|
||||
# python3 -m unittest changedetectionio.tests.unit.test_llm_settings
|
||||
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from changedetectionio.model.LLMSettings import (
|
||||
LLMSettings,
|
||||
LLM_DEFAULT_BUDGET_ACTION,
|
||||
LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER,
|
||||
LLM_DEFAULT_MAX_INPUT_CHARS,
|
||||
LLM_DEFAULT_MAX_SUMMARY_TOKENS,
|
||||
LLM_DEFAULT_THINKING_BUDGET,
|
||||
)
|
||||
|
||||
|
||||
class TestLLMSettingsDefaults(unittest.TestCase):
|
||||
def test_empty_dict_yields_default_model(self):
|
||||
s = LLMSettings.model_validate({})
|
||||
self.assertTrue(s.enabled)
|
||||
self.assertFalse(s.debug)
|
||||
self.assertEqual(s.model, '')
|
||||
self.assertEqual(s.api_key, '')
|
||||
self.assertEqual(s.thinking_budget, LLM_DEFAULT_THINKING_BUDGET)
|
||||
self.assertEqual(s.max_summary_tokens, LLM_DEFAULT_MAX_SUMMARY_TOKENS)
|
||||
self.assertEqual(s.local_token_multiplier, LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER)
|
||||
self.assertEqual(s.max_input_chars, LLM_DEFAULT_MAX_INPUT_CHARS)
|
||||
self.assertEqual(s.budget_action, LLM_DEFAULT_BUDGET_ACTION)
|
||||
self.assertEqual(s.tokens_total_cumulative, 0)
|
||||
self.assertEqual(s.cost_usd_this_month, 0.0)
|
||||
|
||||
def test_default_construct_matches_validate_empty(self):
|
||||
self.assertEqual(LLMSettings().model_dump(), LLMSettings.model_validate({}).model_dump())
|
||||
|
||||
|
||||
class TestLLMSettingsValidation(unittest.TestCase):
|
||||
def test_stripped_keys_validate(self):
|
||||
s = LLMSettings.model_validate({'model': 'gpt-4o-mini', 'enabled': False})
|
||||
self.assertEqual(s.model, 'gpt-4o-mini')
|
||||
self.assertFalse(s.enabled)
|
||||
|
||||
|
||||
class TestLLMSettingsTypeCoercion(unittest.TestCase):
|
||||
def test_select_field_string_int_coerces_to_int(self):
|
||||
# WTForms SelectField returns the choice key as a string ('500');
|
||||
# Pydantic coerces to int so storage stays typed.
|
||||
s = LLMSettings.model_validate({'thinking_budget': '500', 'max_summary_tokens': '5000'})
|
||||
self.assertEqual(s.thinking_budget, 500)
|
||||
self.assertEqual(s.max_summary_tokens, 5000)
|
||||
|
||||
def test_invalid_int_raises(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
LLMSettings.model_validate({'thinking_budget': 'not_a_number'})
|
||||
|
||||
|
||||
class TestLLMSettingsExtraForbid(unittest.TestCase):
|
||||
def test_unknown_key_raises(self):
|
||||
# extra='forbid' is the security gate against CWE-915 mass-assignment.
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
LLMSettings.model_validate({'model': 'gpt-4o-mini', 'evil_field': 'pwn'})
|
||||
self.assertIn('evil_field', str(ctx.exception))
|
||||
|
||||
def test_dunder_key_raises(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
LLMSettings.model_validate({'model': 'gpt-4o-mini', '__class__': 'attack'})
|
||||
|
||||
def test_legitimate_unknown_key_also_raises(self):
|
||||
# No "future-tolerant" silent acceptance — new fields must be declared.
|
||||
with self.assertRaises(ValidationError):
|
||||
LLMSettings.model_validate({'maybe_future_counter': 42})
|
||||
|
||||
def test_legacy_prefixed_key_raises(self):
|
||||
# Pre-update_31 storage used flat application.llm_* keys (handled by the
|
||||
# migration). After migration the prefix is gone — and any code path that
|
||||
# still tries to write a prefixed key into the LLM dict must be rejected
|
||||
# so the prefix can never reappear through any side channel.
|
||||
with self.assertRaises(ValidationError):
|
||||
LLMSettings.model_validate({'llm_model': 'gpt-4o-mini'})
|
||||
|
||||
|
||||
class TestLLMSettingsDumpShapes(unittest.TestCase):
|
||||
def test_dump_uses_field_names(self):
|
||||
s = LLMSettings.model_validate({'model': 'gpt-4o-mini'})
|
||||
out = s.model_dump()
|
||||
self.assertEqual(out['model'], 'gpt-4o-mini')
|
||||
self.assertNotIn('llm_model', out)
|
||||
|
||||
def test_dump_exclude_connection_drops_provider_fields(self):
|
||||
s = LLMSettings.model_validate({
|
||||
'model': 'gpt-4o-mini', 'api_key': 'sk-test', 'api_base': 'https://example',
|
||||
'provider_kind': 'ollama', 'local_token_multiplier': 5,
|
||||
'enabled': False, 'tokens_this_month': 42,
|
||||
})
|
||||
out = s.model_dump(exclude=set(LLMSettings.CONNECTION_FIELDS))
|
||||
for k in LLMSettings.CONNECTION_FIELDS:
|
||||
self.assertNotIn(k, out, f"connection field {k} should be excluded")
|
||||
# Non-connection fields survive
|
||||
self.assertFalse(out['enabled'])
|
||||
self.assertEqual(out['tokens_this_month'], 42)
|
||||
|
||||
|
||||
class TestLLMSettingsFieldGroups(unittest.TestCase):
|
||||
def test_connection_fields_all_declared(self):
|
||||
declared = set(LLMSettings.model_fields)
|
||||
for name in LLMSettings.CONNECTION_FIELDS:
|
||||
self.assertIn(name, declared, f"CONNECTION_FIELDS lists undeclared field: {name}")
|
||||
|
||||
def test_protected_fields_all_declared(self):
|
||||
declared = set(LLMSettings.model_fields)
|
||||
for name in LLMSettings.PROTECTED_FIELDS:
|
||||
self.assertIn(name, declared, f"PROTECTED_FIELDS lists undeclared field: {name}")
|
||||
|
||||
def test_connection_and_protected_disjoint(self):
|
||||
# System-managed counters and user-set provider config must not overlap —
|
||||
# otherwise a "clear credentials" action would also wipe counters.
|
||||
overlap = set(LLMSettings.CONNECTION_FIELDS) & set(LLMSettings.PROTECTED_FIELDS)
|
||||
self.assertEqual(overlap, set(), f"CONNECTION/PROTECTED overlap: {overlap}")
|
||||
|
||||
|
||||
class TestLLMSettingsRoundTrip(unittest.TestCase):
|
||||
def test_counter_round_trip_via_dump_load(self):
|
||||
original = LLMSettings.model_validate({
|
||||
'model': 'gpt-4o-mini',
|
||||
'tokens_total_cumulative': 123456,
|
||||
'tokens_this_month': 789,
|
||||
'tokens_month_key': '2026-05',
|
||||
'cost_usd_total_cumulative': 12.34,
|
||||
'cost_usd_this_month': 0.56,
|
||||
})
|
||||
roundtripped = LLMSettings.model_validate(original.model_dump())
|
||||
self.assertEqual(roundtripped.tokens_total_cumulative, 123456)
|
||||
self.assertEqual(roundtripped.tokens_this_month, 789)
|
||||
self.assertEqual(roundtripped.tokens_month_key, '2026-05')
|
||||
self.assertEqual(roundtripped.cost_usd_total_cumulative, 12.34)
|
||||
self.assertEqual(roundtripped.cost_usd_this_month, 0.56)
|
||||
|
||||
def test_form_merge_preserves_counters(self):
|
||||
# The POST handler pattern: validate existing storage, overlay form input
|
||||
# (with PROTECTED_FIELDS stripped), re-validate. Counters in storage must
|
||||
# survive even if the form somehow tried to set them.
|
||||
existing = LLMSettings.model_validate({
|
||||
'model': 'gpt-4o-mini', 'tokens_total_cumulative': 99999,
|
||||
})
|
||||
form_input = {
|
||||
'model': 'claude-3-5-haiku-20251001',
|
||||
'enabled': False,
|
||||
}
|
||||
# Strip protected fields from form input as the route handler does
|
||||
for protected in LLMSettings.PROTECTED_FIELDS:
|
||||
form_input.pop(protected, None)
|
||||
merged = LLMSettings.model_validate({**existing.model_dump(), **form_input})
|
||||
self.assertEqual(merged.model, 'claude-3-5-haiku-20251001')
|
||||
self.assertFalse(merged.enabled)
|
||||
self.assertEqual(merged.tokens_total_cumulative, 99999)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -223,55 +223,6 @@ Babel auto-discovers the new language on subsequent runs.
|
||||
|
||||
---
|
||||
|
||||
## Dennis linter
|
||||
|
||||
We use [mozilla/dennis](https://github.com/mozilla/dennis) to enforce technical correctness in `.po` and `.pot` files.
|
||||
See the [Table of Warnings and Errors](https://dennis.readthedocs.io/en/latest/linting.html#table-of-warnings-and-errors)
|
||||
for the full list of rules.
|
||||
|
||||
### Running the linter locally
|
||||
|
||||
To match the CI checks, run the following commands:
|
||||
|
||||
```bash
|
||||
# Check for errors only (always enforced)
|
||||
dennis-cmd lint --errorsonly changedetectionio/translations/
|
||||
|
||||
# Check for warnings (excluding W302 unchanged translations)
|
||||
dennis-cmd lint --excluderules=W302 changedetectionio/translations/
|
||||
```
|
||||
|
||||
### Common problems and resolutions
|
||||
|
||||
#### HTML tag mismatch (`W303`)
|
||||
|
||||
The `W303` rule ensures that HTML tags in the `msgstr` match the `msgid`. This is crucial for catching broken markup (e.g., missing closing tags).
|
||||
|
||||
##### Handling intentional deviations
|
||||
|
||||
Some W303 warnings are intentional.
|
||||
Use the `dennis-ignore: W303` comment in the source files (templates or Python code) within a `TRANSLATORS` comment to suppress these warnings.
|
||||
This ensures the ignore instruction is extracted into the `.po` files.
|
||||
|
||||
- **CJK italic policy**: When replacing `<i>` with locale-conventional quotation marks, tags will no longer match.
|
||||
|
||||
**Examples in Jinja2 templates:**
|
||||
|
||||
```jinja
|
||||
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
|
||||
<p>{{ _('These settings are <strong><i>added</i></strong> to any existing watch configurations.')|safe }}</p>
|
||||
```
|
||||
|
||||
**Example in Python source:**
|
||||
|
||||
```python
|
||||
# dennis-ignore: W303 - CJK fonts lack native italics; allow substitution with conventional local styling.
|
||||
message = StringField(_l('This is <i>experimental</i> and may change'))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## CI linter
|
||||
|
||||
A GitHub Actions job (`lint-template-i18n`) checks for adjacent `{{ _(...) }}` calls on the same line
|
||||
|
||||
@@ -2436,7 +2436,7 @@ msgstr "Ціна вище для спрацювання сповіщення"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in % for price changes since the original price"
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Поріг у %% для зміни ціни від початкової"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -162,8 +162,8 @@ msgstr "Es werden 5.000 der ersten URLs aus Ihrer Liste importiert, der Rest kan
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} aus Liste importiert in {duration}s, {skipped_count} übersprungen."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr "{} aus Liste importiert in {:.2f}s, {} übersprungen."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read JSON file, was it broken?"
|
||||
@@ -175,8 +175,8 @@ msgstr "JSON-Struktur sieht ungültig aus, ist sie beschädigt?"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} aus Distill.io importiert in {duration}s, {skipped_count} übersprungen."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr "{} aus Distill.io importiert in {:.2f}s, {} übersprungen."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
@@ -194,18 +194,22 @@ msgstr "Fehler bei der Verarbeitung von Zeile {}, prüfen Sie, ob alle Zelldaten
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgstr "{count} aus Wachete .xlsx importiert in {duration}s"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr "{} aus Wachete .xlsx importiert in {:.2f}s"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgstr "{count} aus benutzerdefinierter .xlsx importiert in {duration}s"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr "{} aus benutzerdefinierter .xlsx importiert in {:.2f}s"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr "URL-Liste"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr "Distill.io"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX & Wachete"
|
||||
@@ -243,7 +247,6 @@ msgstr ""
|
||||
"Kopieren Sie Ihre Distill.io-Watch-„Export“-Datei und fügen Sie sie ein. Dabei sollte es sich um eine JSON-Datei "
|
||||
"handeln."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -316,8 +319,8 @@ msgstr "Passwortschutz entfernt."
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgstr "Warnung: Anzahl der Worker ({worker_count}) nähert sich oder überschreitet die verfügbaren CPU-Kerne ({cpu_count})"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Warnung: Anzahl der Worker ({}) nähert sich oder überschreitet die verfügbaren CPU-Kerne ({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -366,19 +369,13 @@ msgstr "Alle Benachrichtigungen stummgeschaltet."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Alle Benachrichtigungen entstummt."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -402,6 +399,14 @@ msgstr "Globale Filter"
|
||||
msgid "UI Options"
|
||||
msgstr "UI-Optionen"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Backups"
|
||||
@@ -821,13 +826,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -858,10 +856,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -922,23 +916,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -1010,12 +994,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1109,13 +1087,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1146,10 +1118,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1163,7 +1131,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1261,7 +1229,6 @@ msgstr ""
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr "Diese Einstellungen werden zu allen vorhandenen Überwachungskonfigurationen <strong><i>hinzugefügt</i></strong>."
|
||||
@@ -1372,7 +1339,7 @@ msgid ""
|
||||
"watches will be removed from it.</p>"
|
||||
msgstr ""
|
||||
"<p>Möchten Sie wirklich alle Beobachtungen aus der Gruppe <strong>%(title)s</strong> entfernen?</p><p>Das Tag bleibt "
|
||||
"erhalten, aber die Beobachtungen werden daraus entfernt.</p>"
|
||||
"erhalten, aber die Beobachtungen werden daraus entfernt."
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "Unlink"
|
||||
@@ -1485,8 +1452,8 @@ msgstr "1 Überwachung zur erneuten Überprüfung in Warteschlange gestellt."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgstr "{count} Überwachungen zur erneuten Überprüfung eingereiht ({skipped_count} bereits in Warteschlange oder laufend)."
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "{} Überwachungen zur erneuten Überprüfung eingereiht ({} bereits in Warteschlange oder laufend)."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1993,7 +1960,6 @@ msgstr ""
|
||||
"Gut geeignet für Websites, auf denen nur Inhalte verschoben werden und Sie wissen möchten, wann NEUE Inhalte "
|
||||
"hinzugefügt werden. Vergleicht neue Zeilen mit dem gesamten Verlauf dieser Überwachung."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr ""
|
||||
@@ -2259,7 +2225,6 @@ msgstr ""
|
||||
"<p>Möchten Sie den Verlauf für die ausgewählten Elemente wirklich löschen?</p><p>Diese Aktion kann nicht rückgängig "
|
||||
"gemacht werden.</p>"
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr "OK"
|
||||
@@ -2273,10 +2238,10 @@ msgid "Delete Watches?"
|
||||
msgstr "Überwachungen löschen?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
"<p><strong>Möchten Sie die ausgewählten Überwachungen wirklich löschen?</strong></p><p>Diese Aktion kann nicht "
|
||||
"rückgängig gemacht werden.</p>"
|
||||
"<p>Möchten Sie die ausgewählten Überwachungen wirklich löschen?</strong></p><p>Diese Aktion kann nicht rückgängig "
|
||||
"gemacht werden.</p>"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Queued size"
|
||||
@@ -2754,18 +2719,18 @@ msgstr "RegEx „%s“ ist kein gültiger regulärer Ausdruck."
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgstr "„%(expression)s“ ist kein gültiger XPath-Ausdruck. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr "„%s“ ist kein gültiger XPath-Ausdruck. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgstr "„%(expression)s“ ist kein gültiger JSONPath-Ausdruck. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr "„%s“ ist kein gültiger JSONPath-Ausdruck. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgstr "„%(expression)s“ ist kein gültiger JQ-Ausdruck. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr "„%s“ ist kein gültiger JQ-Ausdruck. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Empty value not allowed."
|
||||
@@ -2775,6 +2740,10 @@ msgstr "Leerer Wert nicht zulässig."
|
||||
msgid "Invalid value."
|
||||
msgstr "Ungültiger Wert."
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr "Gruppe / Label"
|
||||
@@ -3013,7 +2982,7 @@ msgstr "Entspricht einer der folgenden Bedingungen"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Verwenden Sie Seite <title> in der Liste"
|
||||
msgstr "Verwenden Sie Seite <Titel> in der Liste"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
@@ -3112,7 +3081,7 @@ msgstr "Favicons Aktiviert"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Verwenden Sie die Seite <title> in der Übersichtsliste der Beobachtungen"
|
||||
msgstr "Verwenden Sie die Seite <Titel> in der Übersichtsliste der Beobachtungen"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API access token security check enabled"
|
||||
@@ -3164,7 +3133,7 @@ msgstr "RSS-Inhaltsformat"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "RSS <description> body built from"
|
||||
msgstr "RSS-<description>-Körper erstellt aus"
|
||||
msgstr "RSS-<Beschreibung>-Körper erstellt aus"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "RSS \"System default\" template override"
|
||||
@@ -3211,19 +3180,23 @@ msgid "API Key"
|
||||
msgstr "API-Schlüssel"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3234,10 +3207,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3246,10 +3215,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3391,8 +3356,9 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Über dem Preis, um eine Benachrichtigung auszulösen"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Schwellenwert (%) für Preisänderungen seit der vorherigen Prüfung"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Schwellenwert in %% für Preisänderungen seit dem ursprünglichen Preis"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
@@ -3469,7 +3435,7 @@ msgstr "Das Protokoll wird nicht unterstützt oder das URL-Format ist ungültig.
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3518,7 +3484,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3640,7 +3606,6 @@ msgstr "Mehr hier"
|
||||
msgid "Use <a target=\"newwindow\" href=\"%(url)s\">AppRise Notification URLs</a> for notification to just about any service!"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr ""
|
||||
@@ -4004,7 +3969,6 @@ msgstr ""
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr ""
|
||||
@@ -4233,17 +4197,6 @@ msgstr "Sprache ändern"
|
||||
msgid "Change language"
|
||||
msgstr "Sprache ändern"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
@@ -160,7 +160,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
@@ -173,7 +173,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
@@ -192,18 +192,22 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ""
|
||||
@@ -237,7 +241,6 @@ msgstr ""
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -308,7 +311,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
@@ -358,19 +361,13 @@ msgstr ""
|
||||
msgid "All notifications unmuted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -394,6 +391,14 @@ msgstr ""
|
||||
msgid "UI Options"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
@@ -803,13 +808,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -840,10 +838,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -904,23 +898,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -992,12 +976,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1091,13 +1069,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1128,10 +1100,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1145,7 +1113,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1243,7 +1211,6 @@ msgstr ""
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr ""
|
||||
@@ -1458,7 +1425,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
@@ -1948,7 +1915,6 @@ msgid ""
|
||||
"lines against all history for this watch."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr ""
|
||||
@@ -2206,7 +2172,6 @@ msgstr ""
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr ""
|
||||
@@ -2220,7 +2185,7 @@ msgid "Delete Watches?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
@@ -2697,17 +2662,17 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -2718,6 +2683,10 @@ msgstr ""
|
||||
msgid "Invalid value."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr ""
|
||||
@@ -3153,19 +3122,23 @@ msgid "API Key"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3176,10 +3149,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3188,10 +3157,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3331,7 +3296,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
@@ -3409,7 +3375,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3458,7 +3424,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3580,7 +3546,6 @@ msgstr ""
|
||||
msgid "Use <a target=\"newwindow\" href=\"%(url)s\">AppRise Notification URLs</a> for notification to just about any service!"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr ""
|
||||
@@ -3942,7 +3907,6 @@ msgstr ""
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr ""
|
||||
@@ -4171,17 +4135,6 @@ msgstr ""
|
||||
msgid "Change language"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
@@ -160,7 +160,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
@@ -173,7 +173,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
@@ -192,18 +192,22 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ""
|
||||
@@ -237,7 +241,6 @@ msgstr ""
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -308,7 +311,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
@@ -358,19 +361,13 @@ msgstr ""
|
||||
msgid "All notifications unmuted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -394,6 +391,14 @@ msgstr ""
|
||||
msgid "UI Options"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
@@ -803,13 +808,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -840,10 +838,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -904,23 +898,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -992,12 +976,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1091,13 +1069,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1128,10 +1100,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1145,7 +1113,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1243,7 +1211,6 @@ msgstr ""
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr ""
|
||||
@@ -1458,7 +1425,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
@@ -1948,7 +1915,6 @@ msgid ""
|
||||
"lines against all history for this watch."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr ""
|
||||
@@ -2206,7 +2172,6 @@ msgstr ""
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr ""
|
||||
@@ -2220,7 +2185,7 @@ msgid "Delete Watches?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
@@ -2697,17 +2662,17 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -2718,6 +2683,10 @@ msgstr ""
|
||||
msgid "Invalid value."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr ""
|
||||
@@ -3153,19 +3122,23 @@ msgid "API Key"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3176,10 +3149,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3188,10 +3157,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3331,7 +3296,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
@@ -3409,7 +3375,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3458,7 +3424,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3580,7 +3546,6 @@ msgstr ""
|
||||
msgid "Use <a target=\"newwindow\" href=\"%(url)s\">AppRise Notification URLs</a> for notification to just about any service!"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr ""
|
||||
@@ -3942,7 +3907,6 @@ msgstr ""
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr ""
|
||||
@@ -4171,17 +4135,6 @@ msgstr ""
|
||||
msgid "Change language"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
@@ -160,8 +160,8 @@ msgstr "Importando 5.000 de las primeras URL de tu lista, el resto se puede impo
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} importado de la lista en {duration}s, {skipped_count} omitido."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr "{} importado de la lista en {:.2f}s, {} omitido."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read JSON file, was it broken?"
|
||||
@@ -173,8 +173,8 @@ msgstr "La estructura JSON parece no válida, ¿estaba rota?"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} importado de Distill.io en {duration}s, {skipped_count} omitido."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr "{} importado de Distill.io en {:.2f}s, {} omitido."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
@@ -194,18 +194,22 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgstr "{count} importado de Wachete .xlsx en {duration}s"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr "{} importado de Wachete .xlsx en {:.2f}s"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgstr "{count} importado desde .xlsx personalizado en {duration}s"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr "{} importado desde .xlsx personalizado en {:.2f}s"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr "Lista de URL"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr "Distill.io"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX y Wachete"
|
||||
@@ -241,7 +245,6 @@ msgstr "Las URL que no pasen la validación permanecerán en el área de texto."
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgstr "Copie y pegue el archivo de 'exportación' del monitor Distill.io, este debería ser un archivo JSON."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -314,10 +317,8 @@ msgstr "Se eliminó la protección con contraseña."
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgstr ""
|
||||
"Advertencia: recuento de trabajadores ({worker_count} ) está cerca o excede los núcleos de CPU disponibles "
|
||||
"({cpu_count} )"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Advertencia: recuento de trabajadores ({} ) está cerca o excede los núcleos de CPU disponibles ({} )"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -366,19 +367,13 @@ msgstr "Todas las notificaciones silenciadas."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Todas las notificaciones activadas."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -402,6 +397,14 @@ msgstr "Filtros globales"
|
||||
msgid "UI Options"
|
||||
msgstr "Opciones de interfaz de usuario"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Copias de seguridad"
|
||||
@@ -841,13 +844,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -878,10 +874,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -942,23 +934,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -1030,12 +1012,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1129,13 +1105,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1166,10 +1136,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1183,7 +1149,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1281,7 +1247,6 @@ msgstr ""
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr "Estas configuraciones son <strong><i>agregadas</i></strong> a cualquier configuración de monitor existente."
|
||||
@@ -1505,8 +1470,8 @@ msgstr "1 monitor en cola para volver a verificar."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgstr "{count} monitores en cola para volver a comprobar ({skipped_count} ya en cola o en ejecución)."
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "{} monitores en cola para volver a comprobar ({} ya en cola o en ejecución)."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -2007,7 +1972,6 @@ msgstr ""
|
||||
"Bueno para sitios web que simplemente mueven el contenido y desea saber cuándo se agrega contenido NUEVO, compara "
|
||||
"nuevas líneas con todo el historial de este monitor."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr ""
|
||||
@@ -2275,7 +2239,6 @@ msgstr ""
|
||||
"<p>¿Está seguro de que desea borrar el historial de los elementos seleccionados?</p><p>Esta acción no se puede "
|
||||
"deshacer.</p>"
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr "OK"
|
||||
@@ -2289,9 +2252,9 @@ msgid "Delete Watches?"
|
||||
msgstr "¿Eliminar monitores?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
"<p><strong>¿Está seguro de que desea eliminar los monitores seleccionados?</strong></p><p>Esta acción no se puede "
|
||||
"<p>¿Está seguro de que desea eliminar los monitores seleccionados?</strong></p><p>Esta acción no se puede "
|
||||
"deshacer.</p>"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
@@ -2324,11 +2287,11 @@ msgstr "Último Comprobado"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changed"
|
||||
msgstr "Cambiado"
|
||||
msgstr "Cambiadp"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Last Changed"
|
||||
msgstr "Último Cambiado"
|
||||
msgstr "Último Cambiadp"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "No web page change detection watches configured, please add a URL in the box above, or"
|
||||
@@ -2770,18 +2733,18 @@ msgstr "Expresión regular '%s' no es una expresión regular válida."
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' no es una expresión XPath válida. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr "'%s' no es una expresión XPath válida. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' no es una expresión JSONPath válida. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr "'%s' no es una expresión JSONPath válida. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' no es una expresión jq válida. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr "'%s' no es una expresión jq válida. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Empty value not allowed."
|
||||
@@ -2791,6 +2754,10 @@ msgstr "Valor vacío no permitido."
|
||||
msgid "Invalid value."
|
||||
msgstr "Valor no válido."
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr "Etiqueta de grupo"
|
||||
@@ -3028,7 +2995,7 @@ msgstr "Coincide con cualquiera de los siguientes"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Usar página <title> en la lista"
|
||||
msgstr "Usar página<title>en la lista"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
@@ -3226,19 +3193,23 @@ msgid "API Key"
|
||||
msgstr "Clave API"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3249,10 +3220,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3261,10 +3228,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3404,8 +3367,9 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Precio superior para activar la notificación"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Umbral (%) para cambios de precio desde la comprobación anterior"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Umbral en %% fo cambios de precio desde el precio original"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
@@ -3482,8 +3446,8 @@ msgstr "El protocolo de visualización no está permitido o el formato de URL no
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgstr "Límite de visualización alcanzado ({current} /{limit} monitores). No se pueden agregar más monitores."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr "Límite de visualización alcanzado ({} /{} monitores). No se pueden agregar más monitores."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
@@ -3519,7 +3483,7 @@ msgstr "El UUID del monitor."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "El título de la página del monitor, utiliza <title> si no se establece, vuelve a la URL"
|
||||
msgstr "El título de la página del monitor, utiliza<title>si no se establece, vuelve a la URL"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The watch group / tag"
|
||||
@@ -3531,7 +3495,7 @@ msgstr "La URL de la página de vista previa generada por changetection.io."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3655,7 +3619,6 @@ msgstr ""
|
||||
"¡Use las <a target=\"newwindow\" href=\"%(url)s\">URL de notificación de AppRise</a> para notificar a casi cualquier "
|
||||
"servicio!"
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr "<i>Lea la wiki de servicios de notificación aquí para obtener notas de configuración importantes</i>"
|
||||
@@ -4019,7 +3982,6 @@ msgstr ""
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr ""
|
||||
@@ -4257,17 +4219,6 @@ msgstr "Cambiar idioma"
|
||||
msgid "Change language"
|
||||
msgstr "Cambiar idioma"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Sí"
|
||||
|
||||
Binary file not shown.
@@ -160,8 +160,8 @@ msgstr "Importation de 5 000 des premières URL de votre liste, le reste peut ê
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} importées de la liste en {duration}s, {skipped_count} ignorées."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr "{} importées de la liste en {:.2f}s, {} ignorées."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read JSON file, was it broken?"
|
||||
@@ -173,8 +173,8 @@ msgstr "La structure JSON semble invalide, est-elle corrompue ?"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} importées de Distill.io en {duration}s, {skipped_count} ignorées."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr "{} importées de Distill.io en {:.2f}s, {} ignorées."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
@@ -194,18 +194,22 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgstr "{count} importées de Wachete .xlsx en {duration}s"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr "{} importées de Wachete .xlsx en {:.2f}s"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgstr "{count} importées de .xlsx personnalisé en {duration}s"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr "{} importées de .xlsx personnalisé en {:.2f}s"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr "Liste d'URL"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr "Distill.io"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX et Wachete"
|
||||
@@ -239,7 +243,6 @@ msgstr "Les URL qui ne passent pas la validation resteront dans la zone de texte
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -312,8 +315,8 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgstr "Avertissement: Le nombre de workers ({worker_count}) approche ou dépasse les cœurs CPU disponibles ({cpu_count})"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Avertissement: Le nombre de workers ({}) approche ou dépasse les cœurs CPU disponibles ({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -362,19 +365,13 @@ msgstr "Toutes les notifications sont désactivées."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Toutes les notifications sont activées."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -398,6 +395,14 @@ msgstr "Filtres globaux"
|
||||
msgid "UI Options"
|
||||
msgstr "Options de l'interface utilisateur"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "SAUVEGARDES"
|
||||
@@ -809,13 +814,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -846,10 +844,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -910,23 +904,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -998,12 +982,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1097,13 +1075,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1134,10 +1106,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1151,7 +1119,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1249,7 +1217,6 @@ msgstr ""
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr ""
|
||||
@@ -1467,8 +1434,8 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgstr "{count} moniteurs mis en file d'attente ({skipped_count} déjà en file ou en cours)."
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "{} moniteurs mis en file d'attente ({} déjà en file ou en cours)."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1959,7 +1926,6 @@ msgid ""
|
||||
"lines against all history for this watch."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr ""
|
||||
@@ -2217,7 +2183,6 @@ msgstr "Effacer les historiques"
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr "D'ACCORD"
|
||||
@@ -2231,7 +2196,7 @@ msgid "Delete Watches?"
|
||||
msgstr "Supprimer les montres ?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
@@ -2710,18 +2675,18 @@ msgstr "RegEx '%s' n'est pas une expression régulière valide."
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' n'est pas une expression XPath valide. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr "'%s' n'est pas une expression XPath valide. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' n'est pas une expression JSONPath valide. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr "'%s' n'est pas une expression JSONPath valide. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' n'est pas une expression jq valide. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr "'%s' n'est pas une expression jq valide. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Empty value not allowed."
|
||||
@@ -2731,6 +2696,10 @@ msgstr "Valeur vide non autorisée."
|
||||
msgid "Invalid value."
|
||||
msgstr "Valeur invalide."
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr "Groupe / Étiquette"
|
||||
@@ -2968,7 +2937,7 @@ msgstr "Faites correspondre l'un des éléments suivants"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Utiliser la page <title> dans la liste"
|
||||
msgstr "Utiliser la page <titre> dans la liste"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of history items per watch to keep"
|
||||
@@ -3067,7 +3036,7 @@ msgstr "Favicons Activés"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Utiliser la page <title> dans la liste de présentation des moniteurs"
|
||||
msgstr "Utiliser la page <titre> dans la liste de présentation des moniteurs"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API access token security check enabled"
|
||||
@@ -3166,19 +3135,23 @@ msgid "API Key"
|
||||
msgstr "Clé API"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3189,10 +3162,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3201,10 +3170,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3344,8 +3309,9 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Au-dessus du prix pour déclencher une notification"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Seuil (%) pour les changements de prix depuis la vérification précédente"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Seuil en %% pour les changements de prix depuis le prix initial"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
@@ -3422,7 +3388,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3471,7 +3437,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3593,7 +3559,6 @@ msgstr "Plus d'infos ici"
|
||||
msgid "Use <a target=\"newwindow\" href=\"%(url)s\">AppRise Notification URLs</a> for notification to just about any service!"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr ""
|
||||
@@ -3957,7 +3922,6 @@ msgstr ""
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr ""
|
||||
@@ -4186,17 +4150,6 @@ msgstr "Changer de langue"
|
||||
msgid "Change language"
|
||||
msgstr "Changer de langue"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Oui"
|
||||
|
||||
Binary file not shown.
@@ -160,8 +160,8 @@ msgstr "Importazione delle prime 5.000 URL dalla tua lista, il resto può essere
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} Importate dalla lista in {duration}s, {skipped_count} Ignorate."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr "{} Importate dalla lista in {:.2f}s, {} Ignorate."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read JSON file, was it broken?"
|
||||
@@ -173,8 +173,8 @@ msgstr "La struttura JSON sembra non valida, è danneggiata?"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} Importate da Distill.io in {duration}s, {skipped_count} Ignorate."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr "{} Importate da Distill.io in {:.2f}s, {} Ignorate."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
@@ -194,18 +194,22 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgstr "{count} importate da Wachete .xlsx in {duration}s"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr "{} importate da Wachete .xlsx in {:.2f}s"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgstr "{count} importate da .xlsx personalizzato in {duration}s"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr "{} importate da .xlsx personalizzato in {:.2f}s"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr "Lista URL"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr "Distill.io"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX & Wachete"
|
||||
@@ -239,7 +243,6 @@ msgstr ""
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -310,8 +313,8 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgstr "Avviso: Il numero di worker ({worker_count}) si avvicina o supera i core CPU disponibili ({cpu_count})"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Avviso: Il numero di worker ({}) si avvicina o supera i core CPU disponibili ({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -360,19 +363,13 @@ msgstr "Tutte le notifiche disattivate."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Tutte le notifiche attivate."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -396,6 +393,14 @@ msgstr "Filtri globali"
|
||||
msgid "UI Options"
|
||||
msgstr "Opzioni interfaccia"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Backup"
|
||||
@@ -805,13 +810,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -842,10 +840,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -906,23 +900,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -994,12 +978,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1093,13 +1071,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1130,10 +1102,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1147,7 +1115,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1245,7 +1213,6 @@ msgstr ""
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr ""
|
||||
@@ -1460,8 +1427,8 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgstr "{count} monitor in coda ({skipped_count} già in coda o in esecuzione)."
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "{} monitor in coda ({} già in coda o in esecuzione)."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1950,7 +1917,6 @@ msgid ""
|
||||
"lines against all history for this watch."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr ""
|
||||
@@ -2208,7 +2174,6 @@ msgstr "Cancella cronologie"
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr "OK"
|
||||
@@ -2222,7 +2187,7 @@ msgid "Delete Watches?"
|
||||
msgstr "Eliminare monitoraggi?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
@@ -2699,18 +2664,18 @@ msgstr "La RegEx '%s' non è un'espressione regolare valida."
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' non è un'espressione XPath valida. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr "'%s' non è un'espressione XPath valida. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' non è un'espressione JSONPath valida. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr "'%s' non è un'espressione JSONPath valida. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' non è un'espressione jq valida. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr "'%s' non è un'espressione jq valida. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Empty value not allowed."
|
||||
@@ -2720,6 +2685,10 @@ msgstr "Valore vuoto non consentito."
|
||||
msgid "Invalid value."
|
||||
msgstr "Valore non valido."
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr "Gruppo / Etichetta"
|
||||
@@ -3155,19 +3124,23 @@ msgid "API Key"
|
||||
msgstr "Chiave API"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3178,10 +3151,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3190,10 +3159,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3333,8 +3298,9 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Prezzo massimo per attivare notifica"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Soglia (%) per le variazioni di prezzo dal controllo precedente"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Soglia in %% per modifiche prezzo dal prezzo originale"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
@@ -3411,7 +3377,7 @@ msgstr "Protocollo non consentito o formato URL non valido"
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3460,7 +3426,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3582,7 +3548,6 @@ msgstr ""
|
||||
msgid "Use <a target=\"newwindow\" href=\"%(url)s\">AppRise Notification URLs</a> for notification to just about any service!"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr ""
|
||||
@@ -3944,7 +3909,6 @@ msgstr ""
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr ""
|
||||
@@ -4173,17 +4137,6 @@ msgstr "Cambia Lingua"
|
||||
msgid "Change language"
|
||||
msgstr "Cambia lingua"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Sì"
|
||||
|
||||
Binary file not shown.
@@ -161,8 +161,8 @@ msgstr "リストの最初の5,000件のURLをインポートしています。
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} 件をリストから {duration}秒でインポートしました。{skipped_count} 件をスキップしました。"
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr "{} 件をリストから {:.2f}秒でインポートしました。{} 件をスキップしました。"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read JSON file, was it broken?"
|
||||
@@ -174,8 +174,8 @@ msgstr "JSONの構造が無効のようです。ファイルが壊れていま
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} 件を Distill.io から {duration}秒でインポートしました。{skipped_count} 件をスキップしました。"
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr "{} 件を Distill.io から {:.2f}秒でインポートしました。{} 件をスキップしました。"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
@@ -193,18 +193,22 @@ msgstr "行番号 {} の処理中にエラーが発生しました。すべて
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgstr "{count} 件を Wachete .xlsx から {duration}秒でインポートしました"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr "{} 件を Wachete .xlsx から {:.2f}秒でインポートしました"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgstr "{count} 件をカスタム .xlsx から {duration}秒でインポートしました"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr "{} 件をカスタム .xlsx から {:.2f}秒でインポートしました"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr "URLリスト"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr "Distill.io"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX & Wachete"
|
||||
@@ -239,7 +243,6 @@ msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON
|
||||
msgstr "Distill.io ウォッチの「エクスポート」ファイルをコピーして貼り付けてください。JSONファイルである必要があります。"
|
||||
|
||||
# 訳注: 日本語を斜体にすると字形が崩れるため、強調表示は<strong>に置き換える。
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -312,8 +315,8 @@ msgstr "パスワード保護が解除されました。"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgstr "警告:ワーカー数({worker_count})が利用可能なCPUコア数({cpu_count})に近いか超えています"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "警告:ワーカー数({})が利用可能なCPUコア数({})に近いか超えています"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -362,19 +365,13 @@ msgstr "すべての通知をミュートしました。"
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "すべての通知のミュートを解除しました。"
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -398,6 +395,14 @@ msgstr "グローバルフィルタ"
|
||||
msgid "UI Options"
|
||||
msgstr "UI オプション"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "バックアップ"
|
||||
@@ -810,13 +815,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -847,10 +845,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -911,23 +905,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -999,12 +983,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1098,13 +1076,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1135,10 +1107,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1152,7 +1120,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1251,7 +1219,6 @@ msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr "タグ名に基づく自動生成色を使用する場合はチェックを外してください。"
|
||||
|
||||
# 訳注: 日本語を斜体にすると字形が崩れるため、<strong> のみで強調し <i> は外す
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr "これらの設定は既存のすべてのウォッチ設定に<strong>追加</strong>されます。"
|
||||
@@ -1466,8 +1433,8 @@ msgstr "1件のウォッチを再チェックのためキューに追加しま
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgstr "{count} 件のウォッチを再チェックのためキューに追加しました({skipped_count} 件はすでにキュー済みまたは実行中)。"
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "{} 件のウォッチを再チェックのためキューに追加しました({} 件はすでにキュー済みまたは実行中)。"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1963,7 +1930,6 @@ msgid ""
|
||||
msgstr "コンテンツを移動させるだけのウェブサイトに適しています。新しいコンテンツが追加されたときに知りたい場合に便利で、このウォッチのすべての履歴と新しい行を比較します。"
|
||||
|
||||
# 訳注: 日本語を斜体にすると字形が崩れるため、参照は「」で囲む。
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr "サイトが行を並べ替えることで発生する不要な変更検知を減らすのに役立ちます。下の「ユニーク行をチェック」と組み合わせてください。"
|
||||
@@ -2225,7 +2191,6 @@ msgstr "履歴をすべてクリア"
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p>選択した項目の履歴をクリアしてもよいですか?</p><p>この操作は元に戻せません。</p>"
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr "OK"
|
||||
@@ -2239,8 +2204,8 @@ msgid "Delete Watches?"
|
||||
msgstr "ウォッチを削除しますか?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p><strong>選択したウォッチを削除してもよいですか?</strong></p><p>この操作は元に戻せません。</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p>選択したウォッチを削除してもよいですか?</strong></p><p>この操作は元に戻せません。</p>"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Queued size"
|
||||
@@ -2716,18 +2681,18 @@ msgstr "正規表現 '%s' は有効な正規表現ではありません。"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' は有効なXPath式ではありません。(%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr "'%s' は有効なXPath式ではありません。(%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' は有効なJSONPath式ではありません。(%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr "'%s' は有効なJSONPath式ではありません。(%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' は有効なjq式ではありません。(%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr "'%s' は有効なjq式ではありません。(%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Empty value not allowed."
|
||||
@@ -2737,6 +2702,10 @@ msgstr "空の値は許可されていません。"
|
||||
msgid "Invalid value."
|
||||
msgstr "無効な値です。"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr "グループタグ"
|
||||
@@ -3172,19 +3141,23 @@ msgid "API Key"
|
||||
msgstr "APIキー"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3195,10 +3168,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3207,10 +3176,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3350,8 +3315,9 @@ msgid "Above price to trigger notification"
|
||||
msgstr "通知をトリガーする上限価格"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "前回のチェックからの価格変動率のしきい値(%)"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "元の価格からの価格変動率のしきい値(%%)"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
@@ -3428,8 +3394,8 @@ msgstr "ウォッチのプロトコルが許可されていないか、URL形式
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgstr "ウォッチの上限に達しました({current}/{limit} ウォッチ)。これ以上ウォッチを追加できません。"
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr "ウォッチの上限に達しました({}/{} ウォッチ)。これ以上ウォッチを追加できません。"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
@@ -3477,8 +3443,8 @@ msgstr "changedetection.io が生成したプレビューページのURL。"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgstr "変更の日時。format= を受け付けます(例: %(call)s)。デフォルトは '%(default)s'。"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr "変更の日時。format= を受け付けます(例: change_datetime(format='%A'))。デフォルトは '%Y-%m-%d %H:%M:%S %Z'。"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The URL of the diff output for the watch."
|
||||
@@ -3606,7 +3572,6 @@ msgid "Use <a target=\"newwindow\" href=\"%(url)s\">AppRise Notification URLs</a
|
||||
msgstr "<a target=\"newwindow\" href=\"%(url)s\">AppRise 通知URL</a> を使えば、ほぼすべてのサービスへの通知に対応できます!"
|
||||
|
||||
# 訳注: 日本語を斜体にすると字形が崩れるため、強調表示は<strong>に置き換える。
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr "<strong>重要な設定に関するメモについては、通知サービスのWikiをこちらでお読みください</strong>"
|
||||
@@ -3976,7 +3941,6 @@ msgid "Note!: //text() function does not work where the <element> contains <![CD
|
||||
msgstr "注意: <element> が <![CDATA[]]> を含む場合、//text() 関数は動作しません"
|
||||
|
||||
# 訳注: 日本語を斜体にすると字形が崩れるため、強調表示は<strong>に置き換える。
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr "1行につき1つのCSS、xPath 1 & 2、JSON Path/JQセレクタを指定してください。一致した<strong>すべての</strong>ルールが使用されます。"
|
||||
@@ -4214,17 +4178,6 @@ msgstr "言語の変更"
|
||||
msgid "Change language"
|
||||
msgstr "言語を変更"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "はい"
|
||||
|
||||
Binary file not shown.
@@ -160,8 +160,8 @@ msgstr "목록의 처음 5,000개 URL만 가져옵니다. 나머지는 다시
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "목록에서 {count}개를 {duration}초 만에 가져왔습니다. {skipped_count}개는 건너뛰었습니다."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr "목록에서 {}개를 {:.2f}초 만에 가져왔습니다. {}개는 건너뛰었습니다."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read JSON file, was it broken?"
|
||||
@@ -173,8 +173,8 @@ msgstr "JSON 구조가 올바르지 않습니다. 파일이 손상되었나요?"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "Distill.io에서 {count}개를 {duration}초 만에 가져왔습니다. {skipped_count}개는 건너뛰었습니다."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr "Distill.io에서 {}개를 {:.2f}초 만에 가져왔습니다. {}개는 건너뛰었습니다."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
@@ -192,18 +192,22 @@ msgstr "{}행 처리 중 오류가 발생했습니다. 모든 셀 데이터 형
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgstr "Wachete .xlsx에서 {count}개를 {duration}초 만에 가져왔습니다."
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr "Wachete .xlsx에서 {}개를 {:.2f}초 만에 가져왔습니다."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgstr "사용자 지정 .xlsx에서 {count}개를 {duration}초 만에 가져왔습니다."
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr "사용자 지정 .xlsx에서 {}개를 {:.2f}초 만에 가져왔습니다."
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr "URL 목록"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr "Distill.io"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX 및 Wachete"
|
||||
@@ -237,7 +241,6 @@ msgstr "유효성 검사를 통과하지 못한 URL은 텍스트 영역에 유
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgstr "Distill.io 모니터링 '내보내기' 파일을 복사해 붙여 넣어 주세요. JSON 파일이어야 합니다."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -310,8 +313,8 @@ msgstr "비밀번호 보호가 해제되었습니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgstr "경고: 워커 수({worker_count})가 사용 가능한 CPU 코어 수({cpu_count})에 근접하거나 초과합니다"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "경고: 워커 수({})가 사용 가능한 CPU 코어 수({})에 근접하거나 초과합니다"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -360,20 +363,14 @@ msgstr "모든 알림이 음소거되었습니다."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "모든 알림의 음소거가 해제되었습니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr "AI / LLM 설정이 제거되었습니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
msgstr "AI 요약 캐시가 지워졌습니다({}개 파일 제거됨)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr "AI 요약 캐시가 지워졌습니다(%(n)s개 파일 제거됨)."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
msgid "Notification debug log"
|
||||
@@ -396,6 +393,14 @@ msgstr "전역 필터"
|
||||
msgid "UI Options"
|
||||
msgstr "UI 옵션"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "백업"
|
||||
@@ -805,13 +810,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr "각 모니터링 또는 태그에 일반 텍스트 판단 기준(%(ex1)s 또는 %(ex2)s)을 지정할 수 있습니다. 변경이 감지될 때마다 AI가 diff를 이 기준과 비교해 불필요한 알림을 줄입니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -844,10 +842,6 @@ msgstr "AI 프로바이더 설정"
|
||||
msgid "AI Provider"
|
||||
msgstr "AI 프로바이더"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr "제3자 데이터 전송 - 읽어 주세요"
|
||||
@@ -912,23 +906,13 @@ msgid "select a provider"
|
||||
msgstr "프로바이더 선택"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr "로컬 / 자체 호스팅"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr "Ollama 또는 사용자 지정/자체 호스팅 엔드포인트에만 필요합니다. 클라우드 프로바이더는 비워 두세요."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr "사용 가능한 모델 불러오기"
|
||||
@@ -1000,12 +984,6 @@ msgstr "모든 요약 캐시 지우기"
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr "모든 모니터링에 저장된 AI 변경 요약 캐시를 제거합니다. 다음 확인 시 다시 생성됩니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr "기본 AI 변경 요약"
|
||||
@@ -1099,14 +1077,8 @@ msgstr "(<code>LLM_MAX_INPUT_CHARS</code>로 설정됨)"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr "문자 - 현재 적용 중: %(limit)s"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr "문자 - 현재 적용 중: %(n)s"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
@@ -1136,10 +1108,6 @@ msgstr "aistudio.google.com → API 키 받기"
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr "로컬 Ollama에는 API 키가 필요 없습니다"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr "openrouter.ai → 키"
|
||||
@@ -1153,8 +1121,8 @@ msgid "Loading…"
|
||||
msgstr "불러오는 중..."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgstr ""
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr "반환된 모델이 없습니다. API 키를 확인하세요."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "— choose a model —"
|
||||
@@ -1251,7 +1219,6 @@ msgstr "사용자 지정 색상"
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr "체크하지 않으면 태그 이름을 기준으로 자동 생성된 색상을 사용합니다."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr "이 설정은 기존 모니터링 설정에 <strong><i>추가</i></strong>됩니다."
|
||||
@@ -1468,8 +1435,8 @@ msgstr "모니터링 1개를 재확인 대기열에 추가했습니다."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgstr "{count}개 모니터링을 재확인 대기열에 추가했습니다. ({skipped_count}개는 이미 대기 중이거나 실행 중)"
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "{}개 모니터링을 재확인 대기열에 추가했습니다. ({}개는 이미 대기 중이거나 실행 중)"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1958,7 +1925,6 @@ msgid ""
|
||||
"lines against all history for this watch."
|
||||
msgstr "사이트가 콘텐츠 위치만 자주 바꾸고 새 콘텐츠가 추가될 때만 알고 싶을 때 유용합니다. 새 줄을 이 모니터링의 전체 기록과 비교합니다."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr "사이트가 줄 순서를 섞어 발생하는 변경 감지를 줄이는 데 도움이 됩니다. 아래의 <i>고유한 줄 확인</i>과 함께 사용해 보세요."
|
||||
@@ -2216,7 +2182,6 @@ msgstr "기록 지우기"
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p>선택한 항목의 기록을 지우시겠습니까?</p><p>이 작업은 되돌릴 수 없습니다.</p>"
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr "확인"
|
||||
@@ -2230,8 +2195,8 @@ msgid "Delete Watches?"
|
||||
msgstr "모니터링을 삭제하시겠습니까?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p><strong>선택한 모니터링을 삭제하시겠습니까?</strong></p><p>이 작업은 되돌릴 수 없습니다.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p>선택한 모니터링을 삭제하시겠습니까?</p><p>이 작업은 되돌릴 수 없습니다.</p>"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Queued size"
|
||||
@@ -2707,18 +2672,18 @@ msgstr "정규식 '%s'은(는) 유효하지 않습니다."
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s'은(는) 유효한 XPath 표현식이 아닙니다. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr "'%s'은(는) 유효한 XPath 표현식이 아닙니다. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s'은(는) 유효한 JSONPath 표현식이 아닙니다. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr "'%s'은(는) 유효한 JSONPath 표현식이 아닙니다. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgstr "'%(expression)s'은(는) 유효한 jq 표현식이 아닙니다. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr "'%s'은(는) 유효한 jq 표현식이 아닙니다. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Empty value not allowed."
|
||||
@@ -2728,6 +2693,10 @@ msgstr "빈 값은 허용되지 않습니다."
|
||||
msgid "Invalid value."
|
||||
msgstr "값이 잘못되었습니다."
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr "그룹 / 태그"
|
||||
@@ -3163,20 +3132,24 @@ msgid "API Key"
|
||||
msgstr "API 키"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgstr "API 기본 URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr "LITELLM_API_KEY 환경 변수를 사용하려면 비워 두세요"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgstr ""
|
||||
msgid "API Base URL"
|
||||
msgstr "API 기본 URL"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr "기본 AI 변경 요약 프롬프트"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
msgstr ""
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr "확인당 최대 토큰 수"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr "최대 누적 토큰 수 (모니터링별)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Monthly token budget"
|
||||
@@ -3186,10 +3159,6 @@ msgstr "월간 토큰 예산"
|
||||
msgid "Max input characters"
|
||||
msgstr "최대 입력 문자 수"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr "{{diff}} 알림 토큰을 AI 요약으로 대체"
|
||||
@@ -3198,10 +3167,6 @@ msgstr "{{diff}} 알림 토큰을 AI 요약으로 대체"
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr "가격 및 재입고 정보 추출의 대체 수단으로 LLM 사용"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr "AI 추론 예산 (토큰)"
|
||||
@@ -3341,8 +3306,9 @@ msgid "Above price to trigger notification"
|
||||
msgstr "다음 가격 초과이면 알림 트리거"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "이전 확인 대비 가격 변동 기준(%)"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "원래 가격 대비 가격 변동 기준(%%)"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
@@ -3419,8 +3385,8 @@ msgstr "모니터링 프로토콜이 허용되지 않거나 URL 형식이 올바
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgstr "모니터링 한도에 도달했습니다. ({current}/{limit}개) 더 이상 모니터링을 추가할 수 없습니다."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr "모니터링 한도에 도달했습니다. ({}/{}개) 더 이상 모니터링을 추가할 수 없습니다."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
@@ -3456,7 +3422,7 @@ msgstr "모니터링 UUID입니다."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "모니터링의 페이지 제목입니다. 설정되지 않았으면 <title> 을 사용하고, 없으면 URL을 사용합니다."
|
||||
msgstr "모니터링의 페이지 제목입니다. 설정되지 않았으면 <title>을 사용하고, 없으면 URL을 사용합니다."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The watch group / tag"
|
||||
@@ -3468,8 +3434,8 @@ msgstr "changedetection.io가 생성한 미리보기 페이지의 URL입니다."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgstr "변경 발생 일시입니다. format= 인자를 사용할 수 있으며 %(call)s 형식입니다. 기본값은 '%(default)s'입니다."
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr "변경 발생 일시입니다. format= 인자를 사용할 수 있으며 change_datetime(format='%A') 형식입니다. 기본값은 '%Y-%m-%d %H:%M:%S %Z'입니다."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The URL of the diff output for the watch."
|
||||
@@ -3590,7 +3556,6 @@ msgstr "더보기"
|
||||
msgid "Use <a target=\"newwindow\" href=\"%(url)s\">AppRise Notification URLs</a> for notification to just about any service!"
|
||||
msgstr "거의 모든 서비스에 알림을 보내려면 <a target=\"newwindow\" href=\"%(url)s\">AppRise 알림 URL</a>을 사용하세요."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr "<i>중요한 설정 참고 사항은 여기의 알림 서비스 위키를 읽어 주세요</i>"
|
||||
@@ -3878,7 +3843,7 @@ msgstr "이 태그/그룹의 모든 모니터링에 판단 기준을 설정합
|
||||
#: changedetectionio/templates/edit/include_llm_intent.html
|
||||
#, python-format
|
||||
msgid "From group '%(name)s': %(value)s"
|
||||
msgstr ""
|
||||
msgstr "'%(name)s' 그룹에서 가져옴: %(value)s"
|
||||
|
||||
#: changedetectionio/templates/edit/include_llm_intent.html
|
||||
msgid "e.g. Alert me when the price drops below $300, or a new product is launched. Ignore footer and navigation changes."
|
||||
@@ -3952,7 +3917,6 @@ msgstr "이 그룹의 AI 기능을 사용하려면 <a href=\"%(url)s\">설정
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr "참고: <element>에 <![CDATA[]]>가 포함된 경우 //text() 함수는 동작하지 않습니다."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr "한 줄에 CSS, XPath 1/2, JSON Path/JQ 선택자를 하나씩 입력하세요. 일치하는 규칙은 <i>모두</i> 사용됩니다."
|
||||
@@ -4191,17 +4155,6 @@ msgstr "언어 변경"
|
||||
msgid "Change language"
|
||||
msgstr "언어 변경"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "예"
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: changedetection.io 0.55.7\n"
|
||||
"Project-Id-Version: changedetection.io 0.55.3\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-18 11:28+0200\n"
|
||||
"POT-Creation-Date: 2026-04-28 15:26+1000\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"
|
||||
@@ -159,7 +159,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
@@ -172,7 +172,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
@@ -191,18 +191,22 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ""
|
||||
@@ -236,7 +240,6 @@ msgstr ""
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -307,7 +310,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
@@ -357,19 +360,13 @@ msgstr ""
|
||||
msgid "All notifications unmuted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -393,6 +390,14 @@ msgstr ""
|
||||
msgid "UI Options"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
@@ -802,13 +807,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -839,10 +837,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -903,23 +897,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -991,12 +975,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1090,13 +1068,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1127,10 +1099,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1144,7 +1112,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1242,7 +1210,6 @@ msgstr ""
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr ""
|
||||
@@ -1457,7 +1424,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
@@ -1947,7 +1914,6 @@ msgid ""
|
||||
"lines against all history for this watch."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr ""
|
||||
@@ -2205,7 +2171,6 @@ msgstr ""
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr ""
|
||||
@@ -2219,7 +2184,7 @@ msgid "Delete Watches?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
@@ -2696,17 +2661,17 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -2717,6 +2682,10 @@ msgstr ""
|
||||
msgid "Invalid value."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr ""
|
||||
@@ -3152,19 +3121,23 @@ msgid "API Key"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3175,10 +3148,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3187,10 +3156,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3330,7 +3295,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
@@ -3408,7 +3374,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3457,7 +3423,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3579,7 +3545,6 @@ msgstr ""
|
||||
msgid "Use <a target=\"newwindow\" href=\"%(url)s\">AppRise Notification URLs</a> for notification to just about any service!"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr ""
|
||||
@@ -3941,7 +3906,6 @@ msgstr ""
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr ""
|
||||
@@ -4170,17 +4134,6 @@ msgstr ""
|
||||
msgid "Change language"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
@@ -161,8 +161,8 @@ msgstr "Importando as primeiras 5.000 URLs da sua lista, o restante pode ser imp
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} Importados da lista em {duration}s, {skipped_count} Ignorados."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr "{} Importados da lista em {:.2f}s, {} Ignorados."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read JSON file, was it broken?"
|
||||
@@ -174,8 +174,8 @@ msgstr "A estrutura do JSON parece inválida, ele está corrompido?"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} Importados do Distill.io em {duration}s, {skipped_count} Ignorados."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr "{} Importados do Distill.io em {:.2f}s, {} Ignorados."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
@@ -193,18 +193,22 @@ msgstr "Erro ao processar a linha {}, verifique se os tipos de dados das célula
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgstr "{count} importados do Wachete .xlsx em {duration}s"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr "{} importados do Wachete .xlsx em {:.2f}s"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgstr "{count} importados do .xlsx personalizado em {duration}s"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr "{} importados do .xlsx personalizado em {:.2f}s"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr "Lista de URLs"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr "Distill.io"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX & Wachete"
|
||||
@@ -238,7 +242,6 @@ msgstr "URLs que não passarem na validação permanecerão na caixa de texto."
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgstr "Copie e cole seu arquivo de 'exportação' do Distill.io, que deve ser um arquivo JSON."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -313,8 +316,8 @@ msgstr "Proteção por senha removida."
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgstr "Aviso: O número de workers ({worker_count}) está próximo ou excede os núcleos de CPU disponíveis ({cpu_count})"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Aviso: O número de workers ({}) está próximo ou excede os núcleos de CPU disponíveis ({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -363,19 +366,13 @@ msgstr "Todas as notificações silenciadas."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Todas as notificações reativadas."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -399,6 +396,14 @@ msgstr "Filtros Globais"
|
||||
msgid "UI Options"
|
||||
msgstr "Opções de Interface"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Backups"
|
||||
@@ -828,13 +833,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -865,10 +863,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -929,23 +923,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -1017,12 +1001,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1116,13 +1094,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1153,10 +1125,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1170,7 +1138,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1268,7 +1236,6 @@ msgstr ""
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr "Estas configurações são <strong><i>adicionadas</i></strong> a quaisquer configurações de monitoramento existentes."
|
||||
@@ -1490,8 +1457,8 @@ msgstr "1 monitoramento enfileirado para rechecagem."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgstr "{count} monitoramentos enfileirados para rechecagem ({skipped_count} já na fila ou rodando)."
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "{} monitoramentos enfileirados para rechecagem ({} já na fila ou rodando)."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1992,7 +1959,6 @@ msgstr ""
|
||||
"Útil para sites que apenas movem o conteúdo de lugar. Se você quer saber quando um NOVO conteúdo é adicionado, isso "
|
||||
"compara novas linhas contra todo o histórico."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr ""
|
||||
@@ -2254,7 +2220,6 @@ msgstr "Limpar Históricos"
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p>Tem certeza que deseja limpar o histórico para os itens selecionados?</p><p>Esta ação não pode ser desfeita.</p>"
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr "OK"
|
||||
@@ -2268,10 +2233,8 @@ msgid "Delete Watches?"
|
||||
msgstr "Excluir Monitoramentos?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
"<p><strong>Tem certeza que deseja excluir os monitoramentos selecionados?</strong></p><p>Esta ação não pode ser "
|
||||
"desfeita.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p>Tem certeza que deseja excluir os monitoramentos selecionados?</strong></p><p>Esta ação não pode ser desfeita.</p>"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Queued size"
|
||||
@@ -2747,18 +2710,18 @@ msgstr "RegEx '%s' não é uma expressão regular válida."
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' não é uma expressão XPath válida. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr "'%s' não é uma expressão XPath válida. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' não é uma expressão JSONPath válida. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr "'%s' não é uma expressão JSONPath válida. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' não é uma expressão jq válida. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr "'%s' não é uma expressão jq válida. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Empty value not allowed."
|
||||
@@ -2768,6 +2731,10 @@ msgstr "Valor vazio não permitido."
|
||||
msgid "Invalid value."
|
||||
msgstr "Valor inválido."
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr "Tag de grupo"
|
||||
@@ -3203,19 +3170,23 @@ msgid "API Key"
|
||||
msgstr "Chave da API"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3226,10 +3197,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3238,10 +3205,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3381,8 +3344,9 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Preço acima deste valor para disparar notificação"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Limite (%) para mudanças de preço desde a verificação anterior"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Limite em %% para mudanças de preço desde o preço original"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
@@ -3459,8 +3423,8 @@ msgstr "O protocolo de monitoramento não é permitido ou o formato da URL é in
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgstr "Limite de monitoramentos atingido ({current}/{limit}). Não é possível adicionar mais."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr "Limite de monitoramentos atingido ({}/{}). Não é possível adicionar mais."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
@@ -3508,8 +3472,8 @@ msgstr "A URL da página de pré-visualização gerada pelo changedetection.io."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgstr "Data/hora da mudança, aceita format=, %(call)s, o padrão é '%(default)s'"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr "Data/hora da mudança, aceita format=, change_datetime(format='%A')', o padrão é '%Y-%m-%d %H:%M:%S %Z'"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The URL of the diff output for the watch."
|
||||
@@ -3632,7 +3596,6 @@ msgstr ""
|
||||
"Use as <a target=\"newwindow\" href=\"%(url)s\">URLs de Notificação AppRise</a> para enviar notificações para quase "
|
||||
"qualquer serviço!"
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr "<i>Por favor, leia a wiki dos serviços de notificação aqui para notas importantes de configuração</i>"
|
||||
@@ -3994,7 +3957,6 @@ msgstr ""
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr ""
|
||||
@@ -4229,17 +4191,6 @@ msgstr "Mudar Idioma"
|
||||
msgid "Change language"
|
||||
msgstr "Mudar idioma"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
Binary file not shown.
@@ -165,8 +165,8 @@ msgstr "Listenizden ilk 5.000 URL içe aktarılıyor, geri kalanı daha sonra te
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} kayıt listeden {duration} saniyede içe aktarıldı, {skipped_count} atlandı."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr "{} kayıt listeden {:.2f} saniyede içe aktarıldı, {} atlandı."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read JSON file, was it broken?"
|
||||
@@ -178,8 +178,8 @@ msgstr "JSON yapısı geçersiz görünüyor, bozuk olabilir mi?"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} kayıt Distill.io'dan {duration} saniyede içe aktarıldı, {skipped_count} atlandı."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr "{} kayıt Distill.io'dan {:.2f} saniyede içe aktarıldı, {} atlandı."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
@@ -197,18 +197,22 @@ msgstr "Satır numarası {} işlenirken hata oluştu, tüm hücre veri tiplerini
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgstr "{count} kayıt Wachete .xlsx dosyasından {duration} saniyede içe aktarıldı"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr "{} kayıt Wachete .xlsx dosyasından {:.2f} saniyede içe aktarıldı"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgstr "{count} kayıt özel .xlsx dosyasından {duration} saniyede içe aktarıldı"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr "{} kayıt özel .xlsx dosyasından {:.2f} saniyede içe aktarıldı"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr "URL Listesi"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr "Distill.io"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX ve Wachete"
|
||||
@@ -244,7 +248,6 @@ msgstr "Doğrulamadan geçemeyen URL'ler metin alanında kalacaktır."
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgstr "Distill.io izleyici 'dışa aktarım' dosyanızı kopyalayıp yapıştırın, bu bir JSON dosyası olmalıdır."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -317,8 +320,8 @@ msgstr "Parola koruması kaldırıldı."
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgstr "Uyarı: Çalışan sayısı ({worker_count}), mevcut CPU çekirdek sayısına ({cpu_count}) yakın veya bunu aşıyor"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Uyarı: Çalışan sayısı ({}), mevcut CPU çekirdek sayısına ({}) yakın veya bunu aşıyor"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -367,19 +370,13 @@ msgstr "Tüm bildirimler sessize alındı."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Tüm bildirimlerin sesi açıldı."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -403,6 +400,14 @@ msgstr "Genel Filtreler"
|
||||
msgid "UI Options"
|
||||
msgstr "Arayüz Seçenekleri"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Yedeklemeler"
|
||||
@@ -838,13 +843,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -875,10 +873,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -939,23 +933,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -1027,12 +1011,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1126,13 +1104,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1163,10 +1135,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1180,7 +1148,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1278,7 +1246,6 @@ msgstr ""
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr "Bu ayarlar, mevcut izleyici yapılandırmalarına <strong><i>eklenerek</i></strong> uygulanır."
|
||||
@@ -1497,8 +1464,8 @@ msgstr "1 izleyici yeniden kontrol için sıraya alındı."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgstr "{count} izleyici yeniden kontrol için sıraya alındı ({skipped_count} tanesi zaten sırada veya çalışıyor)."
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "{} izleyici yeniden kontrol için sıraya alındı ({} tanesi zaten sırada veya çalışıyor)."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1995,7 +1962,6 @@ msgstr ""
|
||||
"Yalnızca içeriği hareket ettiren web siteleri için iyidir ve YENİ içerik eklendiğinde bilmek istersiniz, yeni "
|
||||
"satırları bu izleyicinin tüm geçmişiyle karşılaştırır."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr ""
|
||||
@@ -2259,7 +2225,6 @@ msgstr "Geçmişleri Temizle"
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p>Seçili öğeler için geçmişi temizlemek istediğinizden emin misiniz?</p><p>Bu işlem geri alınamaz.</p>"
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr "Tamam"
|
||||
@@ -2273,8 +2238,8 @@ msgid "Delete Watches?"
|
||||
msgstr "İzleyiciler Silinsin mi?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p><strong>Seçili izleyicileri silmek istediğinizden emin misiniz?</strong></p><p>Bu işlem geri alınamaz.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p>Seçili izleyicileri silmek istediğinizden emin misiniz?</p><p>Bu işlem geri alınamaz.</p>"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Queued size"
|
||||
@@ -2750,18 +2715,18 @@ msgstr "'%s' RegEx'i geçerli bir düzenli ifade değil."
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' geçerli bir XPath ifadesi değil. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr "'%s' geçerli bir XPath ifadesi değil. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' geçerli bir JSONPath ifadesi değil. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr "'%s' geçerli bir JSONPath ifadesi değil. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' geçerli bir jq ifadesi değil. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr "'%s' geçerli bir jq ifadesi değil. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Empty value not allowed."
|
||||
@@ -2771,6 +2736,10 @@ msgstr "Boş değere izin verilmez."
|
||||
msgid "Invalid value."
|
||||
msgstr "Geçersiz değer."
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr "Grup etiketi"
|
||||
@@ -3206,19 +3175,23 @@ msgid "API Key"
|
||||
msgstr "API Anahtarı"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3229,10 +3202,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3241,10 +3210,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3384,8 +3349,9 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Bildirimi tetiklemek için fiyatın üstünde"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Önceki kontrolden bu yana fiyat değişiklikleri için eşik (%)"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Orijinal fiyattan bu yana fiyat değişiklikleri için %% cinsinden eşik"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
@@ -3462,8 +3428,8 @@ msgstr "İzleyici protokolüne izin verilmiyor veya geçersiz URL formatı"
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgstr "İzleyici sınırına ulaşıldı ({current}/{limit} izleyici). Daha fazla izleyici eklenemez."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr "İzleyici sınırına ulaşıldı ({}/{} izleyici). Daha fazla izleyici eklenemez."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
@@ -3511,7 +3477,7 @@ msgstr "changedetection.io tarafından oluşturulan önizleme sayfasının URL's
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3635,7 +3601,6 @@ msgstr ""
|
||||
"<a target=\"newwindow\" href=\"%(url)s\">AppRise Bildirim URL'leri</a> ile hemen hemen her hizmete bildirim "
|
||||
"gönderebilirsiniz!"
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr "<i>Önemli yapılandırma notları için lütfen buradaki bildirim hizmetleri wiki'sini okuyun</i>"
|
||||
@@ -3999,7 +3964,6 @@ msgstr ""
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr ""
|
||||
@@ -4232,17 +4196,6 @@ msgstr "Dili Değiştir"
|
||||
msgid "Change language"
|
||||
msgstr "Dili değiştir"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Evet"
|
||||
|
||||
Binary file not shown.
@@ -159,8 +159,8 @@ msgstr "Імпортуються перші 5000 URL з вашого списк
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from list in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} Імпортовано зі списку за {duration}с, {skipped_count} Пропущено."
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr "{} Імпортовано зі списку за {:.2f}с, {} Пропущено."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read JSON file, was it broken?"
|
||||
@@ -172,8 +172,8 @@ msgstr "Структура JSON виглядає некоректною, мож
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} Imported from Distill.io in {duration}s, {skipped_count} Skipped."
|
||||
msgstr "{count} Імпортовано з Distill.io за {duration}с, {skipped_count} Пропущено."
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr "{} Імпортовано з Distill.io за {:.2f}с, {} Пропущено."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
@@ -191,18 +191,22 @@ msgstr "Помилка обробки рядка {}, перевірте прав
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from Wachete .xlsx in {duration}s"
|
||||
msgstr "{count} імпортовано з Wachete .xlsx за {duration}с"
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr "{} імпортовано з Wachete .xlsx за {:.2f}с"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{count} imported from custom .xlsx in {duration}s"
|
||||
msgstr "{count} імпортовано з власного .xlsx за {duration}с"
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr "{} імпортовано з власного .xlsx за {:.2f}с"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "URL List"
|
||||
msgstr "Список URL"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Distill.io"
|
||||
msgstr "Distill.io"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX та Wachete"
|
||||
@@ -236,7 +240,6 @@ msgstr "URL, що не пройшли перевірку, залишаться
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgstr "Скопіюйте та вставте вміст файлу експорту з Distill.io (файл JSON)."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid ""
|
||||
"This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, "
|
||||
@@ -309,10 +312,8 @@ msgstr "Захист паролем вимкнено."
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({worker_count}) is close to or exceeds available CPU cores ({cpu_count})"
|
||||
msgstr ""
|
||||
"Увага: Кількість воркерів ({worker_count}) наближається до кількості доступних ядер процесора або перевищує її "
|
||||
"({cpu_count})"
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Увага: Кількість воркерів ({}) наближається до кількості доступних ядер процесора або перевищує її ({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -361,19 +362,13 @@ msgstr "Усі сповіщення вимкнено."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Усі сповіщення увімкнено."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
#, python-format
|
||||
msgid "AI summary cache cleared (%(n)s file(s) removed)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
@@ -397,6 +392,14 @@ msgstr "Глобальні фільтри"
|
||||
msgid "UI Options"
|
||||
msgstr "Налаштування інтерфейсу"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Резервні копії"
|
||||
@@ -818,13 +821,6 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -855,10 +851,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -919,23 +911,13 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -1007,12 +989,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1106,13 +1082,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgid "characters — currently enforcing: %(n)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1143,10 +1113,6 @@ msgstr ""
|
||||
msgid "No API key needed for local Ollama"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
@@ -1160,7 +1126,7 @@ msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No models returned by the provider."
|
||||
msgid "No models returned — check your API key."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1258,7 +1224,6 @@ msgstr ""
|
||||
msgid "Leave unchecked to use the auto-generated colour based on the tag name."
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
msgid "These settings are <strong><i>added</i></strong> to any existing watch configurations."
|
||||
msgstr "Ці налаштування <strong><i>додаються</i></strong> до будь-яких існуючих конфігурацій завдань."
|
||||
@@ -1478,8 +1443,8 @@ msgstr "1 завдання додано в чергу на перевірку."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {count} watches for rechecking ({skipped_count} already queued or running)."
|
||||
msgstr "Додано {count} завдань у чергу ({skipped_count} вже в черзі або виконуються)."
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "Додано {} завдань у чергу ({} вже в черзі або виконуються)."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1976,7 +1941,6 @@ msgstr ""
|
||||
"Корисно для сайтів, які просто переміщують контент, коли ви хочете знати лише про НОВИЙ контент. Порівнює нові рядки "
|
||||
"з усією історією цього завдання."
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below."
|
||||
msgstr ""
|
||||
@@ -2238,7 +2202,6 @@ msgstr "Очистити історії"
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p>Ви впевнені, що хочете очистити історію для вибраних елементів?</p><p>Цю дію неможливо скасувати.</p>"
|
||||
|
||||
#. Universally recognized; typically left as-is. dennis-ignore: W302
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "OK"
|
||||
msgstr "ОК"
|
||||
@@ -2252,8 +2215,8 @@ msgid "Delete Watches?"
|
||||
msgstr "Видалити завдання?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p><strong>Ви впевнені, що хочете видалити вибрані завдання?</strong></p><p>Цю дію неможливо скасувати.</p>"
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr "<p>Ви впевнені, що хочете видалити вибрані завдання?</p><p>Цю дію неможливо скасувати.</p>"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Queued size"
|
||||
@@ -2729,18 +2692,18 @@ msgstr "RegEx '%s' не є допустимим регулярним вираз
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid XPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' не є допустимим виразом XPath. (%(error)s)"
|
||||
msgid "'%s' is not a valid XPath expression. (%s)"
|
||||
msgstr "'%s' не є допустимим виразом XPath. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid JSONPath expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' не є допустимим виразом JSONPath. (%(error)s)"
|
||||
msgid "'%s' is not a valid JSONPath expression. (%s)"
|
||||
msgstr "'%s' не є допустимим виразом JSONPath. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
#, python-format
|
||||
msgid "'%(expression)s' is not a valid jq expression. (%(error)s)"
|
||||
msgstr "'%(expression)s' не є допустимим виразом jq. (%(error)s)"
|
||||
msgid "'%s' is not a valid jq expression. (%s)"
|
||||
msgstr "'%s' не є допустимим виразом jq. (%s)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Empty value not allowed."
|
||||
@@ -2750,6 +2713,10 @@ msgstr "Порожнє значення неприпустиме."
|
||||
msgid "Invalid value."
|
||||
msgstr "Неприпустиме значення."
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Group tag"
|
||||
msgstr "Тег групи"
|
||||
@@ -3185,19 +3152,23 @@ msgid "API Key"
|
||||
msgstr "Ключ API"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "API Base URL"
|
||||
msgid "Leave blank to use LITELLM_API_KEY env var"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Token multiplier for local reasoning models"
|
||||
msgid "API Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3208,10 +3179,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3220,10 +3187,6 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -3363,8 +3326,9 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Ціна вище для спрацювання сповіщення"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Поріг (%) для зміни ціни від попередньої перевірки"
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Поріг у %% для зміни ціни від початкової"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
@@ -3441,8 +3405,8 @@ msgstr "Протокол завдання не дозволено або нев
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
|
||||
msgstr "Досягнуто ліміту завдань ({current}/{limit}). Неможливо додати більше."
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr "Досягнуто ліміту завдань ({}/{}). Неможливо додати більше."
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "Body for all notifications — You can use"
|
||||
@@ -3490,7 +3454,7 @@ msgstr "URL сторінки попереднього перегляду, ств
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
#, python-format
|
||||
msgid "Date/time of the change, accepts format=, %(call)s, default is '%(default)s'"
|
||||
msgid "Date/time of the change, accepts format=, change_datetime(format='%A')', default is '%Y-%m-%d %H:%M:%S %Z'"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
@@ -3614,7 +3578,6 @@ msgstr ""
|
||||
"Використовуйте <a target=\"newwindow\" href=\"%(url)s\">URL сповіщень AppRise</a> для сповіщень практично в будь-який"
|
||||
" сервіс!"
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "<i>Please read the notification services wiki here for important configuration notes</i>"
|
||||
msgstr "<i>Будь ласка, прочитайте вікі по сервісах сповіщень тут для важливих нотаток щодо конфігурації</i>"
|
||||
@@ -3976,7 +3939,6 @@ msgstr ""
|
||||
msgid "Note!: //text() function does not work where the <element> contains <![CDATA[]]>"
|
||||
msgstr ""
|
||||
|
||||
#. CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303
|
||||
#: changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used."
|
||||
msgstr ""
|
||||
@@ -4209,17 +4171,6 @@ msgstr "Змінити мову"
|
||||
msgid "Change language"
|
||||
msgstr "Змінити мову"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Так"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user