"""Nachschlagen von Strichcodes bei Open Food Facts. Bewusst über diesen Server statt direkt aus dem Browser: * Die IP-Adresse der Nutzer geht nicht an einen Dritten. Bei einer Abfrage aus dem Browser wüsste Open Food Facts, wer wann welches Produkt scannt - genau die Art von Datenspur, die vermieden werden soll. * Die Content-Security-Policy bleibt bei `connect-src 'self'`. Eine Ausnahme für eine fremde Domain zu öffnen, wäre eine dauerhafte Schwächung für eine gelegentliche Abfrage. * Derselbe Code wird nur einmal abgefragt, egal wie viele Geräte ihn scannen. Das schont auch den fremden Dienst. Abschaltbar über PRODUCT_LOOKUP=off in der .env. Dann bleibt der eigene Artikelstamm die einzige Quelle. Die Daten stammen aus Open Food Facts und stehen unter der Open Database License (ODbL). Für die Verwendung in einer privaten Einkaufsliste ist das unproblematisch; wer sie weiterverbreitet, muss die Lizenz beachten. """ import logging import re from datetime import timedelta from decimal import Decimal, InvalidOperation import httpx from sqlalchemy.orm import Session from app.config import settings from app.models import ProductCache from app.security import utcnow log = logging.getLogger(__name__) API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json" FIELDS = "product_name,product_name_de,generic_name_de,generic_name,brands,quantity" # Open Food Facts verlangt eine aussagekraeftige Kennung. Ohne sie # werden Anfragen abgewiesen. def _user_agent() -> str: return f"{settings.app_name}/1.0 ({settings.public_base_url})" _UNITS = r"kg|g|mg|l|ml|cl|dl|stk|stück|st" # Mehrstueckpackung zuerst pruefen: "6 x 33 cl", "4x500g" _MULTIPACK = re.compile( rf"(?P\d+)\s*[x×*]\s*(?P\d+(?:[.,]\d+)?)\s*(?P{_UNITS})\b", re.IGNORECASE, ) # "500 g", "1,5 l", "250ml" _QUANTITY = re.compile( rf"(?P\d+(?:[.,]\d+)?)\s*(?P{_UNITS})\b", re.IGNORECASE, ) def parse_package( text: str | None, ) -> tuple[int | None, Decimal | None, str | None]: """Zerlegt eine Mengenangabe in Stueckzahl, Gebinde und Einheit. "500 g" -> (None, 500, "g") eine Packung zu 500 g "6 x 33 cl" -> (6, 33, "cl") sechs Flaschen zu je 33 cl Gibt (None, None, None) zurueck, wenn nichts Verwertbares drinsteht - dann werden die Felder nicht vorbelegt, statt zu raten. """ if not text: return None, None, None def normalize(raw: str) -> str: return {"stück": "Stk", "stk": "Stk", "st": "Stk"}.get(raw.lower(), raw.lower()) # Mehrstueckpackung: Stueckzahl und Gebinde getrennt. multi = _MULTIPACK.search(text) if multi: try: count = int(multi.group("count")) size = Decimal(multi.group("value").replace(",", ".")) except (InvalidOperation, ValueError): return None, None, None return count, size, normalize(multi.group("unit")) match = _QUANTITY.search(text) if not match: return None, None, None try: value = Decimal(match.group("value").replace(",", ".")) except InvalidOperation: return None, None, None return None, value, normalize(match.group("unit")) def _fetch(barcode: str) -> dict | None: """Einzelne Abfrage. Gibt None zurueck, wenn nichts gefunden wurde oder der Dienst nicht erreichbar war - der Aufrufer unterscheidet das nicht, weil es fuer ihn dasselbe Ergebnis bedeutet.""" try: response = httpx.get( API_URL.format(barcode=barcode), params={"fields": FIELDS}, headers={"User-Agent": _user_agent()}, timeout=settings.product_lookup_timeout, follow_redirects=True, ) except httpx.HTTPError as exc: log.warning("Produktabfrage für %s fehlgeschlagen: %s", barcode, exc) return None if response.status_code != 200: return None try: data = response.json() except ValueError: return None if data.get("status") != 1: return None product = data.get("product") or {} # Deutsche Bezeichnung bevorzugen, dann die allgemeine. name = ( product.get("product_name_de") or product.get("product_name") or product.get("generic_name_de") or product.get("generic_name") or "" ).strip() if not name: return None brands = (product.get("brands") or "").split(",")[0].strip() package = (product.get("quantity") or "").strip() return { "name": name[:300], "brand": brands[:200] or None, "package": package[:120] or None, } def lookup(db: Session, barcode: str) -> ProductCache | None: """Nachschlagen mit Zwischenspeicher. @returns None, wenn die Funktion abgeschaltet ist. Sonst immer einen Datensatz - auch bei Misserfolg, damit derselbe Code nicht bei jedem Scan erneut nach draußen geht. """ if settings.product_lookup == "off": return None barcode = barcode.strip() if not barcode.isdigit() or not 6 <= len(barcode) <= 20: return None cached = db.get(ProductCache, barcode) if cached is not None: age = utcnow() - cached.fetched_at max_age = timedelta( days=settings.product_cache_days if cached.found else settings.product_miss_days ) if age < max_age: return cached result = _fetch(barcode) count, pack_size, pack_unit = parse_package( result.get("package") if result else None) if cached is None: cached = ProductCache(barcode=barcode) db.add(cached) cached.found = result is not None cached.name = result["name"] if result else None cached.brand = result["brand"] if result else None cached.package = result["package"] if result else None cached.count = count cached.pack_size = pack_size cached.pack_unit = pack_unit cached.source = "openfoodfacts" cached.fetched_at = utcnow() db.commit() db.refresh(cached) return cached