mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-12 14:59:59 +00:00
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
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:
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(',', '')
|
standardized_value = standardized_value.replace(',', '')
|
||||||
else:
|
else:
|
||||||
standardized_value = standardized_value.replace('.', '').replace(',', '.')
|
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:
|
else:
|
||||||
standardized_value = standardized_value.replace(',', '.')
|
standardized_value = standardized_value.replace(',', '.')
|
||||||
|
|
||||||
|
|||||||
@@ -47,12 +47,14 @@ def _deduplicate_prices(data):
|
|||||||
|
|
||||||
if isinstance(datum.value, list):
|
if isinstance(datum.value, list):
|
||||||
# Process each item in the 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()])
|
# parse_currency() understands decimal commas ("39,99"), stripping to [^\d.] first would turn that into 3999
|
||||||
unique_data.update(normalized_value)
|
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:
|
else:
|
||||||
# Process single value
|
# Process single value
|
||||||
v = float(re.sub(r'[^\d.]', '', str(datum.value)))
|
v = Restock().parse_currency(str(datum.value))
|
||||||
unique_data.add(v)
|
if v is not None:
|
||||||
|
unique_data.add(v)
|
||||||
|
|
||||||
return list(unique_data)
|
return list(unique_data)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from html.parser import HTMLParser
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from . import Restock
|
||||||
|
|
||||||
|
|
||||||
class JSONLDExtractor(HTMLParser):
|
class JSONLDExtractor(HTMLParser):
|
||||||
@@ -135,9 +136,9 @@ class MicrodataExtractor(HTMLParser):
|
|||||||
if self.current_itemprop == 'price':
|
if self.current_itemprop == 'price':
|
||||||
# Try to extract numeric price from text
|
# Try to extract numeric price from text
|
||||||
try:
|
try:
|
||||||
price_text = re.sub(r'[^\d.]', '', data.strip())
|
price = Restock().parse_currency(data.strip())
|
||||||
if price_text:
|
if price is not None:
|
||||||
self.microdata['price'] = float(price_text)
|
self.microdata['price'] = price
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
elif self.current_itemprop == 'priceCurrency':
|
elif self.current_itemprop == 'priceCurrency':
|
||||||
@@ -241,7 +242,7 @@ def query_price_availability(extracted_data):
|
|||||||
elif isinstance(price_val, str):
|
elif isinstance(price_val, str):
|
||||||
# Extract numeric value from string
|
# Extract numeric value from string
|
||||||
try:
|
try:
|
||||||
result['price'] = float(re.sub(r'[^\d.]', '', price_val))
|
result['price'] = Restock().parse_currency(price_val)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -44,5 +44,25 @@ class TestDiffBuilder(unittest.TestCase):
|
|||||||
assert any(s in value.get('availability') for s in ['instock', 'instoreonly']), \
|
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"
|
"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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user