"""Open Food Facts Lookup-Client.""" from __future__ import annotations import json import re from typing import NamedTuple import httpx from .config import get_settings settings = get_settings() # Faktoren, um eine Einheit in die Basiseinheit (Gramm bzw. Milliliter) umzurechnen. _UNIT_TO_BASE: dict[str, tuple[str, float]] = { "kg": ("gram", 1000.0), "g": ("gram", 1.0), "mg": ("gram", 0.001), "l": ("milliliter", 1000.0), "dl": ("milliliter", 100.0), "cl": ("milliliter", 10.0), "ml": ("milliliter", 1.0), } _QUANTITY_RE = re.compile(r"([\d]+(?:[.,]\d+)?)\s*(kg|mg|g|dl|cl|ml|l)\b", re.IGNORECASE) #: „3 x 80 g", „6 × 1,5 l", „4 Stück à 125 g" – der Multiplikator steht vorn, #: dazwischen darf ein Zählwort stehen. Bewusst eng gefasst: ein zu weites #: Muster macht aus „12 Eier à 53 g" schnell Unsinn, und eine nicht erkannte #: Stückzahl ist besser als eine falsch geratene. _MULTI_RE = re.compile( r"(?P\d+)\s*(?:st(?:ü|ue)ck|stk\.?|pcs?|st\.?)?\s*" r"(?:[x×*]|à|a)\s*" r"(?P\d+(?:[.,]\d+)?)\s*(?Pkg|mg|g|dl|cl|ml|l)\b", re.IGNORECASE, ) class OffMenge(NamedTuple): """Was aus dem OFF-Feld ``quantity`` herauszulesen ist.""" base_unit: str # "gram" | "milliliter" | "piece" package_size: float | None # Gesamtfuellmenge des Gebindes, in Basiseinheiten stueck: float | None # zaehlbare Teile im Gebinde (nur bei „3 x 80 g") einzelmenge: float | None # Fuellmenge EINES Teils (die 80 g) def parse_gebinde(quantity: str | None) -> OffMenge: """Gesamtmenge UND einen etwaigen Multiplikator aus ``quantity`` lesen. „3 x 80 g" ist nicht dasselbe wie „240 g": die Packung enthaelt drei zaehlbare Riegel. Dieser Multiplikator ging bisher verloren, weil das alte Muster den ERSTEN Zahl-Einheit-Treffer nahm – also die 80. """ if not quantity: return OffMenge("piece", None, None, None) multi = _MULTI_RE.search(quantity) if multi is not None: anzahl = float(multi.group("anzahl")) menge = float(multi.group("menge").replace(",", ".")) base_unit, factor = _UNIT_TO_BASE[multi.group("einheit").lower()] einzel = round(menge * factor, 3) gesamt = round(anzahl * einzel, 3) if anzahl > 0 and gesamt > 0: return OffMenge(base_unit, gesamt, anzahl, einzel) return OffMenge("piece", None, None, None) einfach = _QUANTITY_RE.search(quantity) if einfach is None: return OffMenge("piece", None, None, None) amount = float(einfach.group(1).replace(",", ".")) base_unit, factor = _UNIT_TO_BASE[einfach.group(2).lower()] gesamt = round(amount * factor, 3) return OffMenge(base_unit, gesamt if gesamt > 0 else None, None, None) def parse_quantity(quantity: str | None) -> tuple[str, float | None]: """Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'. Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000), '1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None). Der schmale Vertrag von frueher; die volle Auskunft (mit Multiplikator) liefert ``parse_gebinde``. """ menge = parse_gebinde(quantity) return menge.base_unit, menge.package_size def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None: """Fragt eine Open-Facts-Instanz (OFF oder Open Products Facts) nach einem Barcode. Beide Dienste teilen sich dieselbe API-Struktur; es unterscheidet sich nur die Basis-URL. Gibt ein vorbefülltes Produkt-Dict zurück oder None. """ # v0-API: liefert bei Treffer status=1 + product, bei Nicht-Treffer status=0 # (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404, # weshalb wir bewusst v0 nutzen. url = f"{base_url}/api/v0/product/{barcode}.json" try: resp = httpx.get( url, timeout=settings.off_timeout_seconds, headers={"User-Agent": "Vorrania-Selfhosted/1.0"}, follow_redirects=True, ) except httpx.HTTPError: return None if resp.status_code != 200: return None try: data = resp.json() except ValueError: return None if data.get("status") != 1: return None product = data.get("product", {}) name = ( product.get("product_name_de") or product.get("product_name") or product.get("generic_name") or "" ).strip() if not name: return None categories = product.get("categories") or "" category_tags = product.get("categories_tags") or [] menge = parse_gebinde(product.get("quantity")) return { "barcode": barcode, "name": name, "brand": (product.get("brands") or "").strip() or None, "image_url": product.get("image_front_url") or product.get("image_url") or None, "base_unit": menge.base_unit, "package_size": menge.package_size, "quantity_text": product.get("quantity"), # „3 x 80 g": die Packung enthaelt drei zaehlbare Teile. Als VORSCHLAG # fuer die Zweiteinheit mitgegeben („240 g ≙ 3 Stueck"), nicht gesetzt – # bestaetigen soll es der Nutzer, OFF-Angaben sind nicht immer sauber. "secondary_base": "piece" if menge.stueck else None, "secondary_count": menge.package_size if menge.stueck else None, "secondary_amount": menge.stueck, # Nur fuer den Hinweistext „3 × 80 g". "unit_amount": menge.einzelmenge, "category_suggestion": categories.split(",")[0].strip() if categories else None, "category_tags": category_tags, "source": source, "off_raw": json.dumps(product)[:20000], } def lookup_barcode(barcode: str, prefer: str | None = None) -> dict | None: """Sucht einen Barcode in den offenen Datenbanken. Beim Scannen steht die Kategorie noch nicht fest, deshalb werden beide Quellen der Reihe nach befragt: Lebensmittel (Open Food Facts) und allgemeine Produkte (Open Products Facts). ``prefer="object"`` stellt die allgemeine Produkt-DB nach vorn (z.B. beim erneuten Abgleich eines Gegenstands), sonst gewinnt Essen. """ off = (settings.off_base_url, "off") opf = (settings.opf_base_url, "opf") reihenfolge = [opf, off] if prefer == "object" else [off, opf] for base_url, source in reihenfolge: if not base_url: continue treffer = _lookup_at(barcode, base_url, source) if treffer is not None: return treffer return None