Restock & Price detection - Fix decimal-comma prices ("39,99") being read as 3999 (#4493) (#4494), Warning: Could change previous values that were wrongly stored as 4999 -> 49.99 for example
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s

The extractors stripped everything but [\d.] before converting, so the
decimal comma was thrown away before Restock.parse_currency(), which
already understands it, ever saw the value. Route the values through
parse_currency() instead, and teach it that a lone "1,299" is a
thousands separator so that case does not regress.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jaime KingandClaude Opus 5.5 authored and GitHub committed 2026-10-02 00:04:52 +02:00
1 parent 0e0566721b
commit c8afea1b05
4 files changed
+34 -8

No files matched your search

@@ -26,6 +26,9 @@ class Restock(dict):
standardized_value = standardized_value.replace(',', '')
else:
standardized_value = standardized_value.replace('.', '').replace(',', '.')
elif re.fullmatch(r'\D*\d{1,3}(,\d{3})+\D*', standardized_value):
# Only thousands separators ie "1,299"
standardized_value = standardized_value.replace(',', '')
else:
standardized_value = standardized_value.replace(',', '.')
@@ -47,12 +47,14 @@ def _deduplicate_prices(data):
if isinstance(datum.value, list):
# Process each item in the list
normalized_value = set([float(re.sub(r'[^\d.]', '', str(item))) for item in datum.value if str(item).strip()])
unique_data.update(normalized_value)
# parse_currency() understands decimal commas ("39,99"), stripping to [^\d.] first would turn that into 3999
normalized_value = set([Restock().parse_currency(str(item)) for item in datum.value if str(item).strip()])
unique_data.update(v for v in normalized_value if v is not None)
else:
# Process single value
v = float(re.sub(r'[^\d.]', '', str(datum.value)))
unique_data.add(v)
v = Restock().parse_currency(str(datum.value))
if v is not None:
unique_data.add(v)
return list(unique_data)
@@ -16,6 +16,7 @@ from html.parser import HTMLParser
import json
import re
from loguru import logger
from . import Restock
class JSONLDExtractor(HTMLParser):
@@ -135,9 +136,9 @@ class MicrodataExtractor(HTMLParser):
if self.current_itemprop == 'price':
# Try to extract numeric price from text
try:
price_text = re.sub(r'[^\d.]', '', data.strip())
if price_text:
self.microdata['price'] = float(price_text)
price = Restock().parse_currency(data.strip())
if price is not None:
self.microdata['price'] = price
except ValueError:
pass
elif self.current_itemprop == 'priceCurrency':
@@ -241,7 +242,7 @@ def query_price_availability(extracted_data):
elif isinstance(price_val, str):
# Extract numeric value from string
try:
result['price'] = float(re.sub(r'[^\d.]', '', price_val))
result['price'] = Restock().parse_currency(price_val)
except ValueError:
pass
@@ -44,5 +44,25 @@ class TestDiffBuilder(unittest.TestCase):
assert any(s in value.get('availability') for s in ['instock', 'instoreonly']), \
"a product advertised as available must read as in stock, not out of stock"
def test_price_decimal_comma(self):
"""A decimal comma ("39,99", common for EUR) must not be stripped into 3999"""
from changedetectionio.processors.restock_diff import Restock
from changedetectionio.processors.restock_diff.pure_python_extractor import extract_metadata_pure_python, query_price_availability
for raw, expected in [("39,99", 39.99), ("12.56", 12.56), ("1.299,00", 1299.0), ("1,299.00", 1299.0),
("1,299", 1299.0), ("€ 39,99", 39.99), ("$159", 159.0), ("159", 159.0)]:
assert Restock().parse_currency(raw) == expected, f"parse_currency({raw!r})"
microdata = f"""<div itemscope itemtype="http://schema.org/Product"><div itemprop="offers" itemscope itemtype="http://schema.org/Offer">
<meta itemprop="priceCurrency" content="EUR"><meta itemprop="price" content="{raw}"></div></div>"""
ld_json = f"""<script type="application/ld+json">{{"@context": "https://schema.org", "@type": "Product", "name": "Some Product",
"offers": {{"@type": "Offer", "price": "{raw}", "priceCurrency": "EUR"}}}}</script>"""
microdata_text = f"""<div itemscope itemtype="http://schema.org/Product"><span itemprop="price">{raw}</span></div>"""
assert restock_diff.get_itemprop_availability(microdata).get('price') == expected, f"microdata {raw!r}"
assert restock_diff.get_itemprop_availability(ld_json).get('price') == expected, f"JSON-LD {raw!r}"
assert query_price_availability(extract_metadata_pure_python(ld_json)).get('price') == expected, f"pure python JSON-LD {raw!r}"
assert query_price_availability(extract_metadata_pure_python(microdata_text)).get('price') == expected, f"pure python microdata text {raw!r}"
if __name__ == '__main__':
unittest.main()