Erste Produktivversion
This commit is contained in:
193
backend/app/product_lookup.py
Normal file
193
backend/app/product_lookup.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""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<count>\d+)\s*[x×*]\s*(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>{_UNITS})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# "500 g", "1,5 l", "250ml"
|
||||
_QUANTITY = re.compile(
|
||||
rf"(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>{_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
|
||||
Reference in New Issue
Block a user