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
|
2026-07-31 07:42:13 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 07:42:13 +02:00
|
|
|
|
#: 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 08:38:11 +02:00
|
|
|
|
#: The word directly after a leading amount — the unit candidate in a Swedish line.
|
|
|
|
|
|
_UNIT_TOKEN = re.compile(
|
|
|
|
|
|
rf"^\s*(?:{_AMOUNT})(?:\s*[-–]\s*(?:{_AMOUNT}))?\s+(?P<token>[^\W\d_]+)",
|
|
|
|
|
|
flags=re.UNICODE,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def unit_token(line: str) -> str | None:
|
|
|
|
|
|
"""Return the word following the leading amount, or None if the line has no amount.
|
|
|
|
|
|
|
|
|
|
|
|
This is the unit as *written* (``dl``, ``msk``, ``g``, ``klyfta``). It is the
|
|
|
|
|
|
authority for which unit a row means, because the Mealie NLP parser resolves
|
|
|
|
|
|
``3 dl`` to the ``liter`` record — a verified 10x error on a live instance.
|
|
|
|
|
|
Not every match is a unit (``0,5 citron`` yields ``citron``); callers confirm
|
|
|
|
|
|
the token against the instance's own unit table.
|
|
|
|
|
|
"""
|
|
|
|
|
|
match = _UNIT_TOKEN.match(line)
|
|
|
|
|
|
return match.group("token") if match else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def match_unit(token: str | None, units: list[dict]) -> dict | None:
|
|
|
|
|
|
"""Find the unit record a written token refers to, by name or abbreviation.
|
|
|
|
|
|
|
|
|
|
|
|
Matches singular and plural forms of both. Returns None when the token is not
|
|
|
|
|
|
a unit in this Mealie instance, which is the signal to store no unit at all
|
|
|
|
|
|
rather than the parser's guess.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
return None
|
|
|
|
|
|
needle = token.strip().lower()
|
|
|
|
|
|
fields = ("name", "pluralName", "abbreviation", "pluralAbbreviation")
|
|
|
|
|
|
for unit in units:
|
|
|
|
|
|
if any((unit.get(field) or "").strip().lower() == needle for field in fields):
|
|
|
|
|
|
return unit
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def food_matches_line(food_name: str, line: str) -> bool:
|
|
|
|
|
|
"""True when the parser's food is actually the ingredient the line names.
|
|
|
|
|
|
|
|
|
|
|
|
The parser substitutes near neighbours from the existing food table —
|
|
|
|
|
|
``800 g kycklinglårfilé`` came back as the food ``kycklingfilé``. A food is
|
|
|
|
|
|
accepted only when its name appears in the line outright (``koncentrerad
|
|
|
|
|
|
kycklingfond``) or begins a word in it, which keeps the singular record
|
|
|
|
|
|
``körsbärstomat`` for ``körsbärstomater`` while rejecting the lår/filé swap.
|
|
|
|
|
|
"""
|
|
|
|
|
|
name = (food_name or "").strip().lower()
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
return False
|
|
|
|
|
|
# The name must begin a word, so `lök` does not match inside `vitlök`, while a
|
|
|
|
|
|
# trailing plural (`körsbärstomat` in `körsbärstomater`) is still allowed.
|
|
|
|
|
|
return re.search(rf"(?<![^\W\d_]){re.escape(name)}", line.lower()) is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def line_remainder(line: str, written_unit: str | None, food_name: str | None) -> str:
|
|
|
|
|
|
"""Return what is left of a line once amount, unit, and food are held as structure.
|
|
|
|
|
|
|
|
|
|
|
|
Mealie composes the visible row as ``quantity unit food note``, so ``note`` must
|
|
|
|
|
|
hold only the leftover text. Passing the whole line duplicates it
|
|
|
|
|
|
(``3 dl vispgrädde 3 dl vispgrädde``). The food match is widened to the end of
|
|
|
|
|
|
the word it starts, so the record ``körsbärstomat`` consumes
|
|
|
|
|
|
``körsbärstomater`` and leaves ``(i olika färger)``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
rest = _LEADING_AMOUNT.sub("", line, count=1)
|
|
|
|
|
|
if written_unit:
|
|
|
|
|
|
rest = re.sub(rf"^\s*{re.escape(written_unit)}\b", "", rest, count=1, flags=re.IGNORECASE)
|
|
|
|
|
|
if food_name:
|
|
|
|
|
|
match = re.search(
|
|
|
|
|
|
rf"(?<![^\W\d_]){re.escape(food_name.strip().lower())}[^\W\d_]*", rest.lower()
|
|
|
|
|
|
)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
rest = rest[: match.start()] + rest[match.end() :]
|
|
|
|
|
|
return _WHITESPACE.sub(" ", rest).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def same_line(left: str, right: str) -> bool:
|
|
|
|
|
|
"""Compare two ingredient rows ignoring only whitespace differences."""
|
|
|
|
|
|
return _WHITESPACE.sub(" ", left or "").strip() == _WHITESPACE.sub(" ", right or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
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 (``47⁄10 liter``), duplicated
|
|
|
|
|
|
units, and stray fraction glyphs the Mealie parser emits for Swedish rows.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if any(glyph in line for glyph in ("⁄", "½", "¼", "¾", "⅓", "⅔")):
|
|
|
|
|
|
return True
|
2026-07-31 07:42:13 +02:00
|
|
|
|
# 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
|