"""Rezepte abgleichen: Zutaten von einer Rezeptseite (schema.org/Recipe) oder aus eingefügtem Text lesen, den Produkten im Katalog zuordnen und Fehlendes auf die Einkaufsliste setzen. Abgerufen wird nur die eine Seite, deren Link eingefügt wurde. Chefkoch & Co. betten die Zutaten als JSON-LD ("recipeIngredient") ein; dafür braucht es keine API und keinen HTML-Parser. """ import html import ipaddress import json import re import socket from urllib.parse import urljoin, urlsplit import httpx from fastapi import APIRouter, Body, Depends from .auth import current_user from .db import transaction from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit from .common import ValidationError router = APIRouter() MAX_PAGE = 3 * 1024 * 1024 MAX_REDIRECTS = 3 TIMEOUT = 10 MAX_INGREDIENTS = 80 MAX_TEXT = 10_000 # ---------- Seite abrufen ---------- def _check_host(url: str) -> None: """Nur öffentliche Adressen abrufen – sonst ließe sich über den Server das Heimnetz abfragen.""" parts = urlsplit(url) if parts.scheme not in ("http", "https") or not parts.hostname: raise ValidationError("Bitte einen Link mit http:// oder https:// einfügen.") try: infos = socket.getaddrinfo(parts.hostname, parts.port or (443 if parts.scheme == "https" else 80)) except socket.gaierror: raise ValidationError("Die Seite wurde nicht gefunden. Stimmt der Link?") for info in infos: if not ipaddress.ip_address(info[4][0].split("%")[0]).is_global: raise ValidationError("Links ins eigene Netzwerk werden nicht abgerufen.") async def _fetch(url: str) -> str: headers = {"User-Agent": "Mozilla/5.0 (P42-Dashboard; Rezeptabgleich)", "Accept": "text/html"} async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False, headers=headers) as client: for _ in range(MAX_REDIRECTS + 1): _check_host(url) try: async with client.stream("GET", url) as res: if res.is_redirect and res.headers.get("location"): url = urljoin(url, res.headers["location"]) continue if res.status_code >= 400: raise ValidationError(f"Die Seite antwortet mit Fehler {res.status_code}.") data = bytearray() async for chunk in res.aiter_bytes(): data += chunk if len(data) > MAX_PAGE: raise ValidationError("Die Seite ist zu groß.") return data.decode(res.encoding or "utf-8", errors="replace") except httpx.TimeoutException: raise ValidationError("Die Seite hat nicht rechtzeitig geantwortet.") except httpx.HTTPError: raise ValidationError("Die Seite konnte nicht abgerufen werden.") raise ValidationError("Zu viele Weiterleitungen.") def _find_recipe(page: str): for m in re.finditer(r"]*application/ld\+json[^>]*>(.*?)", page, re.S | re.I): try: data = json.loads(m.group(1).strip()) except ValueError: continue stack = [data] while stack: node = stack.pop() if isinstance(node, list): stack.extend(node) elif isinstance(node, dict): kind = node.get("@type") if kind == "Recipe" or (isinstance(kind, list) and "Recipe" in kind): return node stack.extend(v for k, v in node.items() if k in ("@graph", "mainEntity", "itemListElement")) return None def _servings(value): """recipeYield kommt als Zahl, Text ("4 Portionen") oder Liste davon.""" for v in value if isinstance(value, list) else [value]: m = re.search(r"\d+", str(v or "")) if m and 0 < int(m.group()) <= 100: return int(m.group()) return None # ---------- Zutaten zerlegen ---------- FRACTIONS = {"½": 0.5, "¼": 0.25, "¾": 0.75, "⅓": 1 / 3, "⅔": 2 / 3, "⅛": 0.125} NUMBER = r"(?:\d*[½¼¾⅓⅔⅛]|\d+(?:[.,]\d+)?(?:\s+\d+/\d+)?|\d+/\d+)" AMOUNT = re.compile(rf"^(?P{NUMBER})(?:\s*[-–]\s*(?P{NUMBER}))?\s*") UNITS = { "g", "gr", "gramm", "kg", "mg", "ml", "l", "liter", "cl", "dl", "el", "tl", "essl", "teel", "msp", "messerspitze", "prise", "pck", "päckchen", "packung", "pkt", "dose", "becher", "bund", "zehe", "stück", "stk", "tasse", "glas", "gläser", "scheibe", "schuss", "spritzer", "handvoll", "hand", "blatt", "blätter", "stange", "stängel", "kopf", "würfel", "beutel", "tropfen", "zweig", "knolle", "tüte", "flasche", "rolle", "cm", "etwas", "wenig", "einige", "tafel", "riegel", "netz", "schale", "kugel", } OPTIONAL = re.compile(r"^(?:evtl\.?|eventuell|nach belieben|n\.\s?b\.?|optional|ggf\.?)\s*", re.I) SIZE_WORDS = re.compile( r"^(?:(?:sehr\s+)?(?:kl\.|gr\.|m\.-?\s?groß(?:e|er|es|en)?|" r"(?:groß|klein|mittelgroß|frisch|reif|weich|kalt|warm|lauwarm|gehackt|gemahlen)(?:e|er|es|en)?)\s+)+", re.I, ) # Eiweiß, Eigelb & Co. kauft man als ganze Eier EGG_PARTS = {"eiweiß": "weiß", "eiweiss": "weiß", "eiklar": "weiß", "eigelb": "gelb", "eidotter": "gelb", "dotter": "gelb"} EGG_PART = re.compile(r"^(eiweiß|eiweiss|eiklar|eigelb|eidotter|dotter)(?:e|en|s)?(?=\s|$)") # Selbstverständliches, das meist im Haus ist – nicht vorab ankreuzen STAPLES = {"salz", "pfeffer", "wasser", "salz und pfeffer", "eiswasser", "leitungswasser", "öl zum braten"} def _number(text: str) -> float: text = text.strip().replace(",", ".") if text[-1] in FRACTIONS: return (float(text[:-1]) if text[:-1] else 0) + FRACTIONS[text[-1]] total = 0.0 for part in text.split(): num, _, den = part.partition("/") total += float(num) / float(den) if den else float(num) return total def _recipe_unit(token: str): t = re.sub(r"\((?:n|s|e|en|er)\)$|/(?:n|s|e|en)$", "", token.casefold().rstrip(".,;:")) t = t.rstrip(".") return t if t in UNITS else None def _plural(name: str) -> str: # "Ei(er)" → "Eier", "Zwiebel(n)" → "Zwiebeln", "Zehe/n" → "Zehen" return re.sub(r"(\w)(?:\((\w{1,3})\)|/(\w{1,2}))(?=\W|$)", lambda m: m[1] + (m[2] or m[3]), name) def parse_ingredient(raw: str): text = re.sub(r"\s+", " ", html.unescape(str(raw))).strip() if not text: return None rest = text optional = bool(OPTIONAL.match(rest)) rest = OPTIONAL.sub("", rest) amount = None m = AMOUNT.match(rest) if m: # Bei "2-3" lieber die größere Menge besorgen amount = _number(m["b"] or m["a"]) rest = rest[m.end():] rest = SIZE_WORDS.sub("", rest) # "1 kl. Dose Mais" → Einheit Dose unit = None first, _, tail = rest.partition(" ") if tail and _recipe_unit(first): unit, rest = _recipe_unit(first), tail if first.endswith(","): # "1 TL, gestr. Zimt" → Zusatz hinter dem Komma ist keine Zutat rest = re.sub(r"^[^ ]*\.\s*", "", rest) if re.match(r"^\w+\.\s", rest) else rest # Klammern: "(oder Butter)" ist eine Alternative, alles andere ein Hinweis alternatives = [a.strip() for a in re.findall(r"\(oder ([^)]+)\)", rest, re.I)] name = re.sub(r"\s*\([^)]*\)", "", _plural(rest)) name = name.partition(",")[0] # Hinweise hinter dem Komma ("Saft davon") gehören nicht zum Namen # "Schmand oder Crème fraîche" → Schmand, Crème fraîche als Alternative name, *others = re.split(r"\s+oder\s+", name.strip(), flags=re.I) alternatives += [o.strip() for o in others if o.strip()] name = SIZE_WORDS.sub("", name.strip()).strip(" .-") if not name: return None name = name[0].upper() + name[1:] egg = EGG_PART.match(name.casefold()) if egg or name.casefold() == "ei": name = "Eier" return { "raw": text, "amount": round(amount, 3) if amount is not None else None, "unit": unit, "name": name[:60], "alternatives": [_plural(a)[:60] for a in alternatives], "optional": optional, "staple": name.casefold() in STAPLES, "eggPart": EGG_PARTS[egg[1]] if egg else None, } def merge(ingredients: list) -> list: """Doppelte Zutaten (z. B. Mehl für Teig und Streusel) zusammenfassen, wenn die Einheit passt. Eier: ganze Eier zählen voll, von Eigelb und Eiweiß nur der größere Teil – "3 Eigelb + 3 Eiweiß" sind 3 getrennte Eier, nicht 6. """ merged = {} for ing in ingredients: key = (_key(ing["name"]), ing["unit"]) part = ing.pop("eggPart", None) amount = ing["amount"] if ing["amount"] is not None else (1 if part else None) if key not in merged: merged[key] = {**ing, "amount": None, "raws": [], "whole": 0.0, "parts": {}, "counted": False} entry = merged[key] entry["raws"].append(ing["raw"]) entry["optional"] = entry["optional"] and ing["optional"] if amount is None: continue entry["counted"] = True if part: entry["parts"][part] = entry["parts"].get(part, 0) + amount else: entry["whole"] += amount result = [] for entry in merged.values(): if entry["counted"]: entry["amount"] = round(entry["whole"] + max(entry["parts"].values(), default=0), 3) entry["raw"] = " + ".join(entry["raws"]) for k in ("raws", "whole", "parts", "counted"): del entry[k] result.append(entry) return result # ---------- Zuordnung zu Produkten ---------- _UMLAUTS = str.maketrans({"ä": "a", "ö": "o", "ü": "u", "ß": "ss"}) def _stem(name: str) -> str: """Grobe Grundform, damit "Apfel"/"Äpfel", "Ei"/"Eier", "Tomate"/"Tomaten" zusammenfinden.""" s = name.casefold().translate(_UMLAUTS) if s in ("ei", "eier"): # zu kurz für die Endungsregel; "Eis" soll nicht zu "Eier" werden return "ei" for ending in ("en", "er", "n", "e", "s"): if s.endswith(ending) and len(s) - len(ending) >= 3: return s[: -len(ending)] return s def _matcher(conn): aliases = {r["key"]: r["product"] for r in conn.execute("SELECT key, product FROM ingredient_aliases")} products = [r["name"] for r in conn.execute("SELECT name FROM products")] exact = {_key(p): p for p in products} stems = {} for p in products: stems.setdefault(_stem(p), p) def lookup(name: str): k = _key(name) if k in aliases: return aliases[k] if k in exact: return exact[k] if _stem(name) in stems: return stems[_stem(name)] # "brauner Zucker" → "Zucker" last = name.split()[-1] if last != name: return exact.get(_key(last)) or stems.get(_stem(last)) return None def match(ing: dict) -> dict: found = lookup(ing["name"]) if not found: for alt in ing["alternatives"]: if found := lookup(alt): break return {**ing, "product": found or ing["name"], "known": bool(found)} return match def _result(conn, title, source, servings, raw_ingredients) -> dict: parsed = [p for p in (parse_ingredient(r) for r in raw_ingredients[:MAX_INGREDIENTS]) if p] if not parsed: raise ValidationError("Keine Zutaten gefunden.") match = _matcher(conn) return { "title": title, "source": source, "servings": servings, "ingredients": [match(i) for i in merge(parsed)], } # ---------- API ---------- @router.post("/api/haushalt/rezept") async def load_recipe(data: dict = Body(...), _: dict = Depends(current_user)): """Zutaten aus einem Rezept-Link ({url}) oder eingefügtem Text ({text, servings}) lesen.""" url = data.get("url").strip() if isinstance(data.get("url"), str) else "" text = data.get("text") if isinstance(data.get("text"), str) else "" if url: if not re.match(r"^https?://", url, re.I): url = "https://" + url recipe = _find_recipe(await _fetch(url)) if not recipe or not recipe.get("recipeIngredient"): raise ValidationError( "Auf dieser Seite wurden keine Rezeptdaten gefunden. Du kannst die Zutaten stattdessen einfügen." ) ingredients = recipe["recipeIngredient"] if isinstance(ingredients, str): ingredients = ingredients.splitlines() title = html.unescape(str(recipe.get("name") or "Rezept"))[:200] with transaction() as conn: return _result(conn, title, url, _servings(recipe.get("recipeYield")), ingredients) if text.strip(): if len(text) > MAX_TEXT: raise ValidationError("Der Text ist zu lang.") servings = data.get("servings") servings = servings if isinstance(servings, int) and 0 < servings <= 100 else None lines = [line.strip(" \t-•*·") for line in text.splitlines()] with transaction() as conn: return _result(conn, "Eigene Zutatenliste", None, servings, [line for line in lines if line]) raise ValidationError("Bitte einen Rezept-Link oder eine Zutatenliste angeben.") @router.post("/api/haushalt/rezept/uebernehmen") def take_over(data: dict = Body(...), user: dict = Depends(current_user)): """Ausgewählte Zutaten auf die Einkaufsliste setzen und korrigierte Zuordnungen merken.""" items = data.get("items") if isinstance(data.get("items"), list) else [] aliases = data.get("aliases") if isinstance(data.get("aliases"), list) else [] if not items and not aliases: raise ValidationError("Nichts ausgewählt.") if len(items) > MAX_INGREDIENTS or len(aliases) > MAX_INGREDIENTS: raise ValidationError("Zu viele Zutaten.") # Erst alles prüfen, dann in einem Rutsch speichern checked = [] for i in items: if not isinstance(i, dict): raise ValidationError("Ungültige Zutat.") unit = _unit(i.get("unit")) checked.append((_clean_name(i.get("name")), _quantity(i.get("quantity"), unit), unit)) learned = [(_clean_name(a.get("ingredient")), _clean_name(a.get("product"))) for a in aliases if isinstance(a, dict)] with transaction() as conn: list_id = _get_list(conn, data.get("listId"), user)["id"] if checked else None # Einkauf dieser Liste for ingredient, product in learned: product = _canonical_name(conn, product) if _key(ingredient) == _key(product): conn.execute("DELETE FROM ingredient_aliases WHERE key = ?", (_key(ingredient),)) else: conn.execute( "INSERT INTO ingredient_aliases (key, product) VALUES (?, ?) " "ON CONFLICT (key) DO UPDATE SET product = excluded.product", (_key(ingredient), product), ) for name, quantity, unit in checked: _add(conn, _canonical_name(conn, name), "einkauf", list_id, quantity, unit, user["id"]) return _state(conn, user)