Sichtbarer Name ueberall: Kopfzeichen der Oberflaeche, Seitentitel, Favicon, API-Titel, User-Agent zu Open Food Facts, Dateinamen der Sicherungen, README, Roadmap, Installer- und Update-Skript, npm-Paketname. Bewusst NICHT umbenannt, jeweils mit Begruendung im Code: - POSTGRES_USER/DB und der Volume-Name bleiben "project_good". Ein neuer Volume-Name legt eine leere Datenbank an, ein neuer Benutzername scheitert am bestehenden Volume - vorhandene Bestaende waeren verloren. - Der localStorage-Schluessel bleibt, sonst waeren alle Browser einmal ausgesperrt. - ios/ bleibt unangetastet (wird getrennt bearbeitet). Die Klon-Adresse im README zeigt jetzt auf .../vorrania.git; das Repo in Gitea muss dafuer umbenannt werden. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
102 lines
3.0 KiB
Python
102 lines
3.0 KiB
Python
"""Open Food Facts Lookup-Client."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
|
|
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)
|
|
|
|
|
|
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).
|
|
"""
|
|
if not quantity:
|
|
return "piece", None
|
|
match = _QUANTITY_RE.search(quantity)
|
|
if not match:
|
|
return "piece", None
|
|
amount = float(match.group(1).replace(",", "."))
|
|
base_unit, factor = _UNIT_TO_BASE[match.group(2).lower()]
|
|
package_size = round(amount * factor, 3)
|
|
return base_unit, (package_size if package_size > 0 else None)
|
|
|
|
|
|
def lookup_barcode(barcode: str) -> dict | None:
|
|
"""Fragt Open Food Facts nach einem Barcode.
|
|
|
|
Gibt ein vorbefülltes Produkt-Dict zurück oder None, wenn nicht gefunden.
|
|
"""
|
|
# 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"{settings.off_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 []
|
|
|
|
base_unit, package_size = parse_quantity(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": base_unit,
|
|
"package_size": package_size,
|
|
"quantity_text": product.get("quantity"),
|
|
"category_suggestion": categories.split(",")[0].strip() if categories else None,
|
|
"category_tags": category_tags,
|
|
"source": "off",
|
|
"off_raw": json.dumps(product)[:20000],
|
|
}
|