mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-06-19 23:31:20 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d80850488f | |||
| 24da66f92c | |||
| f58d004070 | |||
| be4718f2b8 | |||
| 6f4cc2d6c1 | |||
| 9adf6a478e | |||
| dd56a502c0 | |||
| baae46deed | |||
| d7a1b67c5a | |||
| b7bb67fac4 |
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
@@ -39,7 +39,7 @@ 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- platform: linux/arm64
|
||||
dockerfile: ./.github/test/Dockerfile-alpine
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
lint-code:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Lint with Ruff
|
||||
run: |
|
||||
pip install ruff
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
lint-translations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Check .po files with msgfmt
|
||||
run: |
|
||||
sudo apt-get install -y gettext
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
lint-template-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@v6
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history and tags for upgrade testing
|
||||
|
||||
|
||||
@@ -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.6'
|
||||
__version__ = '0.55.7'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -99,6 +99,12 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
llm_form_input = dict(form.data.get('llm') or {})
|
||||
|
||||
# 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}
|
||||
|
||||
# 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():
|
||||
|
||||
@@ -757,16 +757,41 @@ 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", b"\xfe\xff")):
|
||||
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")
|
||||
# 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")
|
||||
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:
|
||||
prefix = data[:scan_chars].decode("utf-8")
|
||||
chunk = raw_chunk.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
head = data[:sniff_bytes].decode("ascii", errors="ignore")
|
||||
@@ -774,23 +799,27 @@ def extract_title(data: bytes | str, sniff_bytes: int = 2048, scan_chars: int =
|
||||
enc = m.group(1).lower()
|
||||
else:
|
||||
enc = "cp1252"
|
||||
prefix = data[:scan_chars * 2].decode(enc, errors="replace")
|
||||
chunk = raw_chunk.decode(enc, errors="replace")
|
||||
except Exception as e:
|
||||
logger.error(f"Title extraction encoding detection failed: {e}")
|
||||
return None
|
||||
prefix = chunk
|
||||
case str():
|
||||
prefix = data[:scan_chars] if len(data) > scan_chars else data
|
||||
tag_pos = data.lower().find("<title")
|
||||
if tag_pos == -1:
|
||||
return None
|
||||
prefix = data[tag_pos: tag_pos + _TITLE_WINDOW]
|
||||
case _:
|
||||
logger.error(f"Title extraction received unsupported data type: {type(data)}")
|
||||
return None
|
||||
|
||||
# Search only in the prefix
|
||||
# Search only in the (now tag-anchored) 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
|
||||
@@ -82,10 +82,23 @@ def _check_input_size(text: str, max_chars: int) -> None:
|
||||
|
||||
def _thinking_extra_body(model: str, budget: int) -> dict | None:
|
||||
"""Return litellm extra_body to control thinking for models that support it.
|
||||
For Gemini 2.5+: passes thinkingConfig with the given budget (0 = disabled).
|
||||
For all other models: returns None (no-op).
|
||||
|
||||
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.
|
||||
"""
|
||||
if not model.startswith('gemini/gemini-2.5'):
|
||||
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.
|
||||
return None
|
||||
return {'generationConfig': {'thinkingConfig': {'thinkingBudget': budget}}}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class Restock(dict):
|
||||
'in_stock': None,
|
||||
'price': None,
|
||||
'currency': None,
|
||||
'original_price': None
|
||||
'last_price': None # Price recorded at the most recent check (was misleadingly named 'original_price')
|
||||
}
|
||||
|
||||
# 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 original_price
|
||||
if key == 'price' or key == 'original_price':
|
||||
# Custom logic to handle setting price and last_price
|
||||
if key == 'price' or key == 'last_price':
|
||||
if isinstance(value, str):
|
||||
value = self.parse_currency(raw_value=value)
|
||||
|
||||
@@ -89,7 +89,8 @@ class Watch(BaseWatch):
|
||||
|
||||
def extra_notification_token_values(self):
|
||||
values = super().extra_notification_token_values()
|
||||
values['restock'] = self.get('restock', {})
|
||||
# Copy so the derived 'previous_price' token added below doesn't mutate the stored restock object
|
||||
values['restock'] = dict(self.get('restock', {}))
|
||||
|
||||
values['restock']['previous_price'] = None
|
||||
if self.history_n >= 2:
|
||||
@@ -109,7 +110,7 @@ class Watch(BaseWatch):
|
||||
|
||||
values.append(('restock.price', "Price detected"))
|
||||
values.append(('restock.in_stock', "In stock status"))
|
||||
values.append(('restock.original_price', "Original price at first check"))
|
||||
values.append(('restock.last_price', "Price at the previous 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 in %% for price changes since the original price'), validators=[
|
||||
price_change_threshold_percent = FloatField(_l('Threshold (%) for price changes since the previous check'), 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 % 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>
|
||||
<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>
|
||||
</fieldset>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -564,10 +564,15 @@ class perform_site_check(difference_detection_processor):
|
||||
# Main detection method
|
||||
fetched_md5 = None
|
||||
|
||||
# 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')
|
||||
# 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')}'). ")
|
||||
|
||||
if not self.fetcher.instock_data and not itemprop_availability.get('availability') and not itemprop_availability.get('price'):
|
||||
raise ProcessorException(
|
||||
@@ -617,9 +622,13 @@ 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'))
|
||||
# Default to current price if no previous price found
|
||||
if watch['restock'].get('original_price'):
|
||||
previous_price = float(watch['restock'].get('original_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'}")
|
||||
# It was different, but negate it further down
|
||||
if price != previous_price:
|
||||
changed_detected = True
|
||||
@@ -642,11 +651,14 @@ 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 %
|
||||
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'))
|
||||
# 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'))
|
||||
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
|
||||
|
||||
@@ -825,3 +825,25 @@ class DatastoreUpdatesMixin:
|
||||
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)")
|
||||
|
||||
|
||||
@@ -196,6 +196,81 @@ 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):
|
||||
"""
|
||||
|
||||
@@ -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' or b'1960.45' in res.data #depending on locale
|
||||
assert b'1,960.45' in res.data or b'1960.45' in res.data #depending on locale
|
||||
assert b'has-unread-changes' in res.data
|
||||
|
||||
|
||||
@@ -295,7 +295,28 @@ 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' or b'1950.45' in res.data #depending on locale
|
||||
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'has-unread-changes' not in res.data
|
||||
|
||||
|
||||
@@ -349,9 +370,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.original_price}}' in res.data
|
||||
assert b'{{restock.last_price}}' in res.data
|
||||
assert b'{{restock.previous_price}}' in res.data
|
||||
assert b'Original price at first check' in res.data
|
||||
assert b'Price at the previous check' in res.data
|
||||
|
||||
#####################
|
||||
# Set this up for when we remove the notification from the watch, it should fallback with these details
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/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()
|
||||
@@ -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.
@@ -584,7 +584,7 @@ msgstr "Pozn.: Toto je aplikováno globálně dodatečně k pravidlům nastaven
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
msgstr "Text shody bude ignorován ve snímku textu (bude i nadále vidět, ale nebude spouštět upozornění na změnu)"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
|
||||
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
|
||||
@@ -772,23 +772,23 @@ msgstr "Využití"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Overview"
|
||||
msgstr ""
|
||||
msgstr "Přehled"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Provider"
|
||||
msgstr ""
|
||||
msgstr "Poskytovatel"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Prompts"
|
||||
msgstr ""
|
||||
msgstr "Prompty"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Behaviour"
|
||||
msgstr ""
|
||||
msgstr "Chování"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "AI-powered change monitoring"
|
||||
msgstr ""
|
||||
msgstr "AI podporované sledování změn"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Connect an LLM to move from \"something changed\" to \"only the thing you care about changed\"."
|
||||
@@ -925,31 +925,31 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
msgstr "Načíst dostupné modely"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Available models"
|
||||
msgstr ""
|
||||
msgstr "Dostupné modely"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "choose a model"
|
||||
msgstr ""
|
||||
msgstr "vybrat model"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Enter API key and click 'Load available models'"
|
||||
msgstr ""
|
||||
msgstr "Vložit API klíč a kliknout na 'Načíst dostupné modely'"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Remove AI / LLM configuration?"
|
||||
msgstr ""
|
||||
msgstr "Odstranit AI / LLM konfiguraci?"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "This will remove your saved AI provider, model, and API key."
|
||||
msgstr ""
|
||||
msgstr "Toto odstraní uloženého poskytovatele AI, model a API klíč."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Remove"
|
||||
msgstr ""
|
||||
msgstr "Odstranit"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html
|
||||
@@ -958,7 +958,7 @@ msgstr "Zrušit"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Test connection"
|
||||
msgstr ""
|
||||
msgstr "Otestovat připojení"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
@@ -1294,7 +1294,7 @@ msgstr "Sledovat skupinu / Značka"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "Groups allows you to manage filters and notifications for multiple watches under a single organisational tag."
|
||||
msgstr ""
|
||||
msgstr "Skupiny umožňují spravovat filtry a upozornění pro vícero sledování seskupené pod jedním organizačním tagem."
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "# Watches"
|
||||
@@ -1329,7 +1329,7 @@ msgstr "Smazat skupinu?"
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
#, python-format
|
||||
msgid "<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
msgstr "<p>Opravdu chcete smazat skupinu <strong>%(title)s</strong>?</p><p>Tuto akci nelze vzít zpět.</p>"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
@@ -1350,6 +1350,8 @@ msgid ""
|
||||
"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but "
|
||||
"watches will be removed from it.</p>"
|
||||
msgstr ""
|
||||
"<p>Opravud chcete odpojit všechna sledování pod skupinou <strong>%(title)s</strong>?</p><p>Tag bude zachován, ale "
|
||||
"podřazená sledování budou odstraněna.</p>"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "Unlink"
|
||||
@@ -1391,22 +1393,22 @@ msgstr "{} sledování ztlumeno"
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches un-muted"
|
||||
msgstr ""
|
||||
msgstr "{} sledování zesíleno"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches queued for rechecking"
|
||||
msgstr ""
|
||||
msgstr "{} sledování ve frontě ke kontrole"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches errors cleared"
|
||||
msgstr ""
|
||||
msgstr "{} chyb sledování vyčištěno"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches cleared/reset."
|
||||
msgstr ""
|
||||
msgstr "{} sledování vyčištěno/resetováno."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1416,7 +1418,7 @@ msgstr "{} monitorů nastaveno na použití výchozího nastavení oznámení"
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches were tagged"
|
||||
msgstr ""
|
||||
msgstr "{} sledování otagováno"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Watch not found"
|
||||
@@ -1433,7 +1435,7 @@ msgstr "jasný"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "History clearing started in background"
|
||||
msgstr ""
|
||||
msgstr "Čištění historie spuštěno na pozadí"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Incorrect confirmation text."
|
||||
@@ -1442,7 +1444,7 @@ msgstr "Žádné informace"
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
msgstr ""
|
||||
msgstr "Sledování s UUID {} neexistuje."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Deleted."
|
||||
@@ -1450,15 +1452,15 @@ msgstr "Smazáno"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Cloned, you are editing the new watch."
|
||||
msgstr ""
|
||||
msgstr "Naklonováno, upravujete nové sledování."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Watch is already queued or being checked."
|
||||
msgstr ""
|
||||
msgstr "Sledování je již zařazeno do fronty ke kontrole."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr ""
|
||||
msgstr "Zařazeno 1 sledování ke kontrole."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1477,7 +1479,7 @@ msgstr "Přidává se sledování do fronty pro opětovnou kontrolu na pozadí..
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Could not share, something went wrong while communicating with the share server - {}"
|
||||
msgstr ""
|
||||
msgstr "Sdílení selhalo, něco se pokazilo při komunikaci se sdílecím serverem = {}"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Language set to auto-detect from browser"
|
||||
@@ -1485,52 +1487,52 @@ msgstr "Jazyk nastaven na automatickou detekci z prohlížeče"
|
||||
|
||||
#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py
|
||||
msgid "No history found for the specified link, bad link?"
|
||||
msgstr ""
|
||||
msgstr "Historie pro vybraný odkaz nenalezena, chybný odkaz?"
|
||||
|
||||
#: changedetectionio/blueprint/ui/diff.py
|
||||
msgid "Not enough history (2 snapshots required) to show difference page for this watch."
|
||||
msgstr ""
|
||||
msgstr "Nedostatečná historie (vyžadovány 2 záchyty) pro zobrazení rozdílů tohoto sledování."
|
||||
|
||||
#: changedetectionio/blueprint/ui/diff.py
|
||||
#, python-format
|
||||
msgid "Monthly AI token budget of %(budget)s tokens reached (%(used)s used). Resets next month."
|
||||
msgstr ""
|
||||
msgstr "Dosažen měsíční počet %(budget)s AI tokenů (%(used)s použito). Resetuje se příští měsíc."
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
msgid "No watches to edit"
|
||||
msgstr ""
|
||||
msgstr "Žádná sledování k úpravě"
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "No watch with the UUID {} found."
|
||||
msgstr ""
|
||||
msgstr "Sledování s UUID {} nenalezeno."
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Switched to mode - {}."
|
||||
msgstr ""
|
||||
msgstr "Přepnuto na mód - {}."
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
msgstr "Nelze načíst '{}' procesor, zásuvný modul procesoru nejspíše chybí. Vyberte prosím jiný procesor."
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
msgstr "Nelze načíst '{}' procesor, zásuvný modul procesoru nejspíše chybí."
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
msgid "System settings default"
|
||||
msgstr ""
|
||||
msgstr "Výchozí systémová nastavení"
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Výchozí"
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
msgid "Updated watch - unpaused!"
|
||||
msgstr ""
|
||||
msgstr "Sledování aktualizováno - znovu spuštěno!"
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
msgid "Updated watch."
|
||||
@@ -1538,15 +1540,15 @@ msgstr "Sledování aktualizováno."
|
||||
|
||||
#: changedetectionio/blueprint/ui/preview.py
|
||||
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
|
||||
msgstr ""
|
||||
msgstr "Náhled nedostupný - stažení/kontrola nedokončena nebo nebyly splněny podmínky kontroly"
|
||||
|
||||
#: changedetectionio/blueprint/ui/preview.py
|
||||
msgid "Diff"
|
||||
msgstr ""
|
||||
msgstr "Rozdíly"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
|
||||
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
|
||||
msgstr ""
|
||||
msgstr "Toto odstraní historii verzí (snímky) pro VŠECHNA sledování, ale ponechá seznam URL adres!"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
|
||||
msgid "You may like to use the"
|
||||
@@ -3337,9 +3339,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Vyšší cena pro spuštění upozornění"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Prahová hodnota v %% pro změny ceny od původní ceny"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Prahová hodnota (%) pro změny ceny od předchozí kontroly"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
Binary file not shown.
@@ -3391,9 +3391,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Über dem Preis, um eine Benachrichtigung auszulösen"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Schwellenwert in %% für Preisänderungen seit dem ursprünglichen Preis"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Schwellenwert (%) für Preisänderungen seit der vorherigen Prüfung"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
@@ -3331,8 +3331,7 @@ msgid "Above price to trigger notification"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
|
||||
@@ -3331,8 +3331,7 @@ msgid "Above price to trigger notification"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
|
||||
Binary file not shown.
@@ -3404,9 +3404,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Precio superior para activar la notificación"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Umbral en %% fo cambios de precio desde el precio original"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Umbral (%) para cambios de precio desde la comprobación anterior"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
Binary file not shown.
@@ -3344,9 +3344,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Au-dessus du prix pour déclencher une notification"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Seuil en %% pour les changements de prix depuis le prix initial"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Seuil (%) pour les changements de prix depuis la vérification précédente"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
Binary file not shown.
@@ -3333,9 +3333,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Prezzo massimo per attivare notifica"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Soglia in %% per modifiche prezzo dal prezzo originale"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Soglia (%) per le variazioni di prezzo dal controllo precedente"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
Binary file not shown.
@@ -3350,9 +3350,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "通知をトリガーする上限価格"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "元の価格からの価格変動率のしきい値(%%)"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "前回のチェックからの価格変動率のしきい値(%)"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
Binary file not shown.
@@ -3341,9 +3341,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "다음 가격 초과이면 알림 트리거"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "원래 가격 대비 가격 변동 기준(%%)"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "이전 확인 대비 가격 변동 기준(%)"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: changedetection.io 0.55.6\n"
|
||||
"Project-Id-Version: changedetection.io 0.55.7\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-25 17:59+0200\n"
|
||||
"POT-Creation-Date: 2026-06-18 11:28+0200\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -3330,8 +3330,7 @@ msgid "Above price to trigger notification"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
|
||||
Binary file not shown.
@@ -3381,9 +3381,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Preço acima deste valor para disparar notificação"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, 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"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Limite (%) para mudanças de preço desde a verificação anterior"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
Binary file not shown.
@@ -3384,9 +3384,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Bildirimi tetiklemek için fiyatın üstünde"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, 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"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Önceki kontrolden bu yana fiyat değişiklikleri için eşik (%)"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
Binary file not shown.
@@ -3363,9 +3363,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "Ціна вище для спрацювання сповіщення"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "Поріг у %% для зміни ціни від початкової"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "Поріг (%) для зміни ціни від попередньої перевірки"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
Binary file not shown.
@@ -3336,9 +3336,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "高于该价格时触发通知"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "相对原始价格的变动阈值(百分比)"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "相对上次检查的价格变动阈值(%)"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
Binary file not shown.
@@ -3335,9 +3335,8 @@ msgid "Above price to trigger notification"
|
||||
msgstr "高於此價格觸發通知"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
#, python-format
|
||||
msgid "Threshold in %% for price changes since the original price"
|
||||
msgstr "自原始價格以來價格變化的閾值(%%)"
|
||||
msgid "Threshold (%) for price changes since the previous check"
|
||||
msgstr "自上次檢查以來價格變化的閾值(%)"
|
||||
|
||||
#: changedetectionio/processors/restock_diff/forms.py
|
||||
msgid "Should be between 0 and 100"
|
||||
|
||||
@@ -70,6 +70,9 @@ services:
|
||||
# For complete privacy if you don't want to use the 'check version' / telemetry service
|
||||
# - DISABLE_VERSION_CHECK=true
|
||||
#
|
||||
# Disable all LLM / AI features, prompts etc
|
||||
# - LLM_FEATURES_DISABLED=true
|
||||
#
|
||||
# A valid timezone name to run as (for scheduling watch checking) see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
# - TZ=America/Los_Angeles
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user