Compare commits

...

10 Commits

Author SHA1 Message Date
dependabot[bot] d80850488f Bump actions/checkout from 6 to 7 in the all group
Bumps the all group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 00:23:12 +00:00
dgtlmoon 24da66f92c Restock - Threshold change since "first check" was working really as "since last check", update UI, tests, field name.
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-06-18 12:01:41 +02:00
dgtlmoon f58d004070 Restock - test fix 2026-06-18 10:49:24 +02:00
Charles Rossi be4718f2b8 fix: extract <title> from pages with large <head> sections (#4217) (#4220)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
Co-authored-by: Charles Rossi <charles@choreless.dev>
2026-06-15 13:52:28 +02:00
dgtlmoon 6f4cc2d6c1 Updating language catalog
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-06-03 12:07:40 +02:00
Jaroslav Lichtblau 9adf6a478e feat: Czech translation updated and refined (#4203)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-06-02 09:25:43 +02:00
dgtlmoon dd56a502c0 Update docker-compose.yml - adding LLM_FEATURES_DISABLED example
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-29 15:12:41 +02:00
dgtlmoon baae46deed 0.55.7
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-25 18:47:29 +02:00
dgtlmoon d7a1b67c5a UI - LLM - Fix for settings (wtforms vs pydantic) (#4184) 2026-05-25 18:43:33 +02:00
dgtlmoon b7bb67fac4 LLM - Smarter reasoning budget logic for gemini models 2026-05-25 18:03:11 +02:00
46 changed files with 446 additions and 142 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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():
+38 -9
View File
@@ -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
+16 -3
View File
@@ -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
+22
View File
@@ -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&eacute; &amp; 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
@@ -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"
@@ -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
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
+3 -4
View File
@@ -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
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
+3
View File
@@ -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
#