Initial commit: Mealie MCP server
Ports a working Hermes agent skill to an MCP server. The skill's value was not its Mealie endpoints but ~25 operational rules found by running imports against the live instance; those are now code with tests rather than prompt text. The server owns deterministic mechanics — auth, the non-default User-Agent Cloudflare requires, the `extension` field on cover uploads, PATCH instead of the 500-ing bulk-action endpoints, decimal-comma normalization, and restoring Swedish display text after parsing. Language and taste judgement stay with the model, fed by the four reference notes shipped as MCP resources. verify_recipe runs the finished-import definition as code so an agent cannot report success on a recipe with an English ingredient line, a missing cover, or ingredients still in the "Click Parse" state. Home Assistant is optional and isolated; the Obsidian meal plan is out of scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""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
|
||||
|
||||
#: 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
|
||||
|
||||
|
||||
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
|
||||
if re.search(r"\b(\w+)\s+\1\b", line, flags=re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
Reference in New Issue
Block a user