194 lines
6.0 KiB
Python
194 lines
6.0 KiB
Python
"""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
|