Files
mealie-mcp/mealie_mcp/normalize.py
T

167 lines
6.2 KiB
Python
Raw Normal View History

2026-07-31 07:31:10 +02:00
"""Text normalization for Swedish recipe content.
Pure functions, no I/O. These encode pitfalls verified against Fredrik's live
Mealie instance — see resources/import-pitfalls.md.
"""
from __future__ import annotations
import re
from decimal import ROUND_HALF_UP, Decimal
2026-07-31 07:31:10 +02:00
#: Campaign/navigation suffixes seen appended to imported titles, notably from Köket.
TITLE_JUNK_SUFFIXES = (
"- se & gör",
"- se och gör",
"| Köket.se",
"| Köket",
"- Recept",
"- recept",
)
#: Placeholder text Mealie writes when a scrape fails to find real content.
EXTRACTION_FAILURE_MARKERS = (
"Could not detect ingredients",
"Could not detect instructions",
)
#: English units/labels that must not survive into a finished Swedish recipe.
ENGLISH_LEFTOVERS = (
"cup", "cups", "tbsp", "tsp", "tablespoon", "teaspoon",
"oz", "ounce", "lb", "pound", "clove", "cloves",
"servings", "ingredients", "instructions",
)
_DECIMAL_COMMA = re.compile(r"(?<=\d),(?=\d)")
_WHITESPACE = re.compile(r"\s+")
def normalize_for_parser(line: str) -> str:
"""Return a variant of a Swedish ingredient line the Mealie parser handles better.
Only converts decimal comma to decimal point (``4,7 dl`` -> ``4.7 dl``).
The result is for the parser only; the original line stays canonical for
``display``/``note``, because the parser mangles Swedish text.
"""
return _WHITESPACE.sub(" ", _DECIMAL_COMMA.sub(".", line)).strip()
def clean_title(title: str) -> str:
"""Strip campaign/navigation junk that import scrapers append to recipe titles."""
cleaned = title.strip()
changed = True
while changed:
changed = False
for suffix in TITLE_JUNK_SUFFIXES:
if cleaned.lower().endswith(suffix.lower()):
cleaned = cleaned[: -len(suffix)].strip(" -|")
changed = True
return _WHITESPACE.sub(" ", cleaned).strip()
def slugify(title: str) -> str:
"""Build a Mealie-style slug from a Swedish title, preserving å/ä/ö transliteration."""
text = title.lower()
for source, target in (("å", "a"), ("ä", "a"), ("ö", "o"), ("é", "e"), ("&", "och")):
text = text.replace(source, target)
text = re.sub(r"[^a-z0-9]+", "-", text)
return text.strip("-")
def find_english_leftovers(text: str) -> list[str]:
"""Return English unit/label words still present in supposedly Swedish content."""
lowered = text.lower()
return [word for word in ENGLISH_LEFTOVERS if re.search(rf"\b{re.escape(word)}\b", lowered)]
def has_extraction_failure(recipe: dict) -> bool:
"""Detect Mealie's 'could not detect' placeholders anywhere in a recipe."""
haystack = [recipe.get("description") or ""]
haystack += [i.get("display") or i.get("note") or "" for i in recipe.get("recipeIngredient") or []]
haystack += [s.get("text") or "" for s in recipe.get("recipeInstructions") or []]
blob = " ".join(haystack)
return any(marker in blob for marker in EXTRACTION_FAILURE_MARKERS)
def ingredient_display_lines(recipe: dict) -> list[str]:
"""Extract the human-facing ingredient lines, preferring display over note."""
lines = []
for item in recipe.get("recipeIngredient") or []:
text = item.get("display") or item.get("note") or ""
if not text:
food = item.get("food") or {}
text = food.get("name") or ""
lines.append(text)
return lines
#: One amount: a decimal ("4,7"), a fraction ("1/2"), or a mixed number ("1 1/2").
_AMOUNT = r"\d+(?:[.,]\d+)?(?:\s+\d+/\d+)?|\d+/\d+"
#: A leading amount in a Swedish ingredient line, optionally a range:
#: "800 g ...", "4,7 dl ...", "1 klyfta ...", "1-1,5 msk ...", "1 1/2 msk ...".
_LEADING_AMOUNT = re.compile(
rf"^\s*(?P<low>{_AMOUNT})(?:\s*[-]\s*(?P<high>{_AMOUNT}))?(?=\s|[^\d.,/-]|$)"
)
def parse_amount(raw: str) -> Decimal:
"""Read a Swedish/ASCII amount: ``4,7``, ``1/2``, or the mixed number ``1 1/2``."""
text = raw.strip().replace(",", ".")
whole = Decimal(0)
if " " in text:
head, text = text.split(None, 1)
whole = Decimal(head)
if "/" in text:
numerator, denominator = text.split("/", 1)
return whole + Decimal(numerator) / Decimal(denominator)
return whole + Decimal(text)
def format_amount(value: Decimal) -> str:
"""Render a scaled amount the way the recipe library writes them.
Whole numbers lose the decimal part (``1200``, not ``1200.0``) and fractions
use the Swedish decimal comma (``1,5``) to match the stored ingredient lines.
"""
quantized = value.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
if quantized == quantized.to_integral_value():
return str(int(quantized))
return format(quantized, "f").replace(".", ",")
def scale_line(line: str, factor: Decimal) -> tuple[str, bool]:
"""Scale the leading amount of an ingredient line.
Returns the line and whether anything was scaled. The amount is read from the
display text rather than the structured ``quantity`` field, because unparsed
recipes carry ``quantity: 0.0`` while the real number lives in the text — and
because ``str(800.0)`` never substring-matches ``"800 g ..."``.
"""
match = _LEADING_AMOUNT.match(line)
if not match:
return line, False
def scaled(raw: str) -> str:
return format_amount(parse_amount(raw) * factor)
low = scaled(match.group("low"))
high = match.group("high")
# A range must move at both ends: "1-1,5 msk" doubled is "2-3 msk", not "2-1,5 msk".
replacement = f"{low}-{scaled(high)}" if high else low
return line[: match.start("low")] + replacement + line[match.end() :], True
2026-07-31 07:31:10 +02:00
def looks_mangled(line: str) -> bool:
"""Heuristic for parser-damaged display text.
Verified failure modes: vulgar/composed fractions (``4710 liter``), duplicated
units, and stray fraction glyphs the Mealie parser emits for Swedish rows.
"""
if any(glyph in line for glyph in ("", "½", "¼", "¾", "", "")):
return True
# Duplicated words, but only alphabetic ones: "2 dl dl grädde" is parser damage,
# while the mixed number in "1 1/2 msk tomatpuré" is perfectly readable.
if re.search(r"\b([^\W\d_]+)\s+\1\b", line, flags=re.IGNORECASE):
2026-07-31 07:31:10 +02:00
return True
return False