Files
einkaufsapp/backend/app/product_lookup.py

196 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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:
from app.runtime_settings import get_duration
age = utcnow() - cached.fetched_at
max_age = timedelta(
days=get_duration(db, "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