ellie@princess: ~
ellie.tty terminal princess

ellie@princess ~ $ bat ~/blog/hacking-mealie-for-fun-and-dinner.md

cd ../blog
2026-07-14 | 15 min read

Hacking Mealie for fun and dinner

#mealie #openai #gpt-5.6 #python #kubernetes #opentofu #self-hosting

My Mealie recipe library, now considerably more international and significantly less allergic to metric units.

There is a very specific kind of optimism involved in clicking Import recipe and expecting a food blog to become structured data.

The page has a 900-word childhood memory, three newsletter pop-ups, an autoplaying video, twelve affiliate links, two incompatible versions of the recipe, and—somewhere beneath the GDPR consent manager—a list containing 1 cup of flour.

Computers love this. It is basically archaeology, except every pottery fragment has a Pinterest button attached.

I only wanted somewhere nice to keep recipes.

More specifically, I wanted to cook food from more cultures and stop cycling through the same handful of meals like a cron job with low self-esteem. Swedish comfort food is lovely. So are Sichuan pork, Malaysian mushroom korma, Serbian meat rolls, Polish cabbage rolls, Italian ragù, British cakes, and whatever delicious thing I discover at 23:40 while browsing recipes instead of sleeping.

That is how I arrived at Mealie: an open-source, self-hosted recipe manager. It stores recipes, images, categories and tags; builds meal plans and shopping lists; scales quantities; organizes cookbooks; and imports recipes from the web. In other words, it is the sensible domestic application I needed, so naturally I deployed it onto Kubernetes with PostgreSQL, OIDC, persistent Ceph storage, Gateway API routing, and backups.

Some people keep recipes in a binder. I keep mine behind a CloudNativePG cluster. We all have coping mechanisms.

The incident report

Mealie’s URL importer is genuinely useful. Paste a recipe URL and, in the happy path, a complete recipe appears with its title, image, ingredients, instructions, times and servings. That is the kind of feature that feels like magic until you import recipes from several countries and discover that the magic has regional settings.

One recipe says 2 cups flour. Another says 200 millilitres whipping cream. A third says 1 EL Öl. Temperatures alternate between Celsius and Fahrenheit depending on which side of the Atlantic currently has custody of the oven. Instructions arrive with decorative entries such as Make the sauce occupying a whole step, followed by the actual instruction in the next step. Ingredient parsing occasionally decides that preparation notes are foods, units are philosophical suggestions, and fractions are an attack on its family.

What I wanted was less “preserve whatever the page happened to publish” and more this:

 1Source:
 21 cup water
 3Bake at 375°F.
 4Make the sauce:
 5Add the garlic, lemon juice and stock to the pan.
 6
 7Normalized:
 8240 ml water
 9Bake at 190°C.
10To make the sauce, add the garlic, lemon juice and stock to the pan.

Nothing revolutionary. Just consistent measurements, an oven setting compatible with the continent where my oven lives, and instructions that do not mistake typography for cooking.

Translation adds another layer. I do not want culturally specific dish names flattened into bland English product copy, but I do want instructions in one language I can reliably follow while holding a hot pan. Hui Guo Rou should remain Hui Guo Rou; the paragraph explaining when to add the doubanjiang can be English. This is a semantic decision, not a string replacement.

Under the hood, Mealie’s first URL strategy uses hhursev/recipe-scrapers, an impressive Python library with parsers for hundreds of recipe sites plus schema.org/JSON-LD support. For extraction, this is excellent engineering. Most competent recipe sites already publish machine-readable Recipe data because search engines reward them for doing so. The scraper can collect that data deterministically, quickly, locally, and without paying an API bill every time somebody wants soup.

The limitation is not that recipe-scrapers fails at its job. The limitation is that its job is scraping.

It can tell me the source says 1 cup all-purpose flour. It should not casually decide whether that means 120 g, 125 g, or 140 g, because volume-to-weight conversion depends on the ingredient and sometimes on how enthusiastically somebody packed the cup. It should not rewrite an entire Polish recipe into natural English, decide that a visual section heading is not a cooking step, preserve a culturally meaningful name, and keep the converted temperature consistent everywhere it occurs.

That is no longer extraction. That is editorial normalization with consequences. A deterministic scraper is a very good postal worker; I was becoming annoyed that it would not also translate the letter, convert the measurements, remove the advertising, and check whether the instructions made sense. A wildly unfair performance review.

The fallback that never fell back

Mealie already has OpenAI integration. It also supports custom prompt files through OPENAI_CUSTOM_PROMPT_DIR. Lovely. I wrote custom prompts, mounted them into the container, enabled an AI provider, and imported a normal recipe URL.

Nothing changed.

This was not an AI problem. It was an ordered list problem, the natural predator of engineering confidence.

At the time of this deployment, Mealie’s default scraper order was:

1DEFAULT_SCRAPER_STRATEGIES = [
2    RecipeScraperPackage,
3    RecipeScraperOpenAITranscription,
4    RecipeScraperOpenAI,
5    RecipeScraperOpenGraph,
6]

Mealie tries each strategy and returns the first usable result. RecipeScraperPackage is the wrapper around recipe-scrapers, and on most recipe pages it succeeds. That means the loop returns before RecipeScraperOpenAI ever gets a turn. My magnificent custom scrape prompt was mounted correctly and being ignored with flawless reliability.

Fallbacks only happen when the first choice fails. A result can be technically successful and still not be the result you wanted. Distributed systems engineers know this as “Tuesday.”

So I applied a tiny, deeply impolite patch:

1DEFAULT_SCRAPER_STRATEGIES = [
2    RecipeScraperOpenAI,
3    RecipeScraperPackage,
4    RecipeScraperOpenAITranscription,
5    RecipeScraperOpenGraph,
6]

That is the whole behavioral change. OpenAI gets first refusal; the regular scraper remains as a fallback.

I call it monkey-patching because that communicates the correct level of shame, although technically it is a containerized file overlay. My OpenTofu creates a ConfigMap containing the patched recipe_scraper.py and mounts it over the installed module inside Mealie’s container:

1volume_mount {
2  name       = "scraper-patch"
3  mount_path = "/opt/mealie/lib/python3.12/site-packages/mealie/services/scraper/recipe_scraper.py"
4  sub_path   = "recipe_scraper.py"
5  read_only  = true
6}

The custom prompts are mounted separately under /app/custom-prompts, and Mealie is pointed there with:

1OPENAI_CUSTOM_PROMPT_DIR=/app/custom-prompts

Checksums of the environment, Secret, prompt data and Python patch live in the Deployment’s pod-template annotations, so changing any of them causes Kubernetes to roll the pod. Infrastructure as code is wonderful because even my crimes against package ownership are reproducible.

There is an obvious maintenance bill: the patched file is copied from Mealie’s installed version. Every Mealie upgrade now requires comparing that upstream module and verifying the path, imports and strategy interface still match. If upstream changes the scraper pipeline, my ConfigMap could replace new code with an old assumption and produce cuisine-flavoured sadness.

Do not cargo-cult this patch into production and then blame Python when an upgrade explodes. The repository README contains a large warning because future me deserves at least one witness.

What the AI path actually does

The clever part is that Mealie does not ask the model to emit its internal database objects directly.

RecipeScraperOpenAI strips the page into text, appends its JSON-LD blocks, finds a likely recipe image, and sends that material to the model with the recipes.scrape-recipe prompt. The model returns a JSON representation of a schema.org Recipe. Mealie then wraps that JSON in a tiny fake HTML document as an application/ld+json script and passes it back through the existing package scraper and cleaner.

1web page
2  -> visible text + JSON-LD + likely image
3  -> GPT-5.6 Luna
4  -> JSON representation of a schema.org Recipe
5  -> synthetic <script type="application/ld+json">
6  -> existing recipe-scrapers/Mealie cleaning path
7  -> stored recipe

It is an adapter wearing a fake moustache so the mature import pipeline accepts the model’s output. I mean that affectionately. Reusing the existing validation and cleaning path is much safer than creating a second route into the database just because the words “AI integration” have entered the meeting.

Choosing Luna instead of hiring the Sun

I used GPT-5.6 Luna, the fastest and least expensive member of the GPT-5.6 family.

The family has three tiers:

  • Sol is the flagship: the most capable option for hard coding, scientific, cyber and long-running agentic work.
  • Terra balances capability and cost for everyday professional work.
  • Luna is the fastest, lowest-cost tier.

At launch, OpenAI priced GPT-5.6 per million tokens at $5 input / $30 output for Sol, $2.50 / $15 for Terra, and $1 / $6 for Luna. The ratios are wonderfully easy: for the same token profile, Terra costs 2.5 times Luna and Sol costs five times Luna.

Sol would be spectacular overkill here. I am not asking the model to prove a theorem, coordinate agents across a week-long migration, or discover a novel compiler vulnerability. I am asking it to look at 8 oz cream cheese and return roughly 225 g cream cheese without turning the author’s life story into Step 4.

Terra would also do the job, but the workload does not justify paying 2.5 times more unless testing shows a meaningful quality difference. It did not. Recipe extraction is bounded, repetitive, strongly instructed, and produces structured output. Luna already has much more linguistic and reasoning ability than this task needs.

Using the biggest available model for every workload is not architecture. It is standing at the kitchen counter slicing chives with a rescue helicopter.

The prompts became the product

The default Mealie prompts are intentionally compact. Its stock scrape-recipe.txt is essentially: extract recipe data from webpage content as schema.org Recipe, do not invent information, and return {} when there is not enough data. The stock ingredient prompt adds sensible basics about order, ambiguity, ranges, multilingual grammar and common units.

Those defaults have to work for everyone. Mine only has to work for my kitchen, where I want English recipe text, metric measurements, Celsius ovens, exact unit abbreviations and no instruction step whose sole contribution is Make the sauce.

I could not get that behavior from the default prompts because they never ask for it. Models are capable, not psychic. “Extract this recipe” does not secretly mean “apply Ellie’s house style, but preserve the epistemological integrity of dumpling nomenclature.”

The full custom prompts live with the deployment, and I have also published both prompts in their full, gloriously over-specified form for anyone who wants to read the actual incantations instead of my civilized summary. Their jobs break down like this.

Prompt one: scrape and normalize the whole recipe

scrape-recipe.txt is the large one. It tells Luna to:

  1. Find the actual recipe. Prefer the author’s content over navigation, SEO text, advertising, comments, subscription prompts and related recipes. Deduplicate responsive layouts, print views and repeated metadata.
  2. Return one schema.org Recipe object. Unsupported fields are omitted, insufficient pages become {}, and invented data is forbidden.
  3. Normalize measurements sensibly. US customary units become practical metric values, Fahrenheit becomes Celsius, but teaspoons, tablespoons, counts and package units remain when conversion would make the recipe worse.
  4. Avoid laboratory cosplay. 1 cup water can become about 240 ml; it must not become 236.588 ml, because my measuring jug has not completed a metrology doctorate.
  5. Use a unit house style. Metric units become mg, g, kg, ml and l; kitchen units become tsp and tbsp. No grams in one ingredient, g in the next, and grammes arriving later with a fake passport.
  6. Keep countable things countable. 4 cloves garlic remains natural language. It does not become 4 units garlic cloves, a phrase only a procurement database could love.
  7. Clean the instructions. Every step needs a concrete cooking action. Heading-only steps are removed or merged into the following action. Temperatures and repeated quantities must agree with the normalized ingredient list.
  8. Translate into natural English. Titles, descriptions, ingredients and instructions are translated, while proper nouns, brands, place names and culturally specific dish names survive when translation would reduce precision.
  9. Choose the food image. Prefer the main finished-dish image, reject logos, portraits, ads, pixels and decorative nonsense, and return one absolute URL.
  10. Audit itself. The prompt ends with a validation checklist covering ingredient order, unit forms, practical quantities, action-only instructions, image URLs and unsupported claims.

That final checklist mattered more than I expected. Long prompts are not automatically good prompts; often they are merely documentation that bills by the token. But explicit invariants gave Luna something concrete to verify before returning the JSON.

Prompt two: parse ingredients without eating any

After importing, Mealie can parse ingredient strings into structured quantity, unit, food and note fields. My parse-recipe-ingredients.txt prompt treats that as a lossless transformation:

1"2 tbsp olive oil, plus more for frying"
2
3quantity: 2
4unit: tbsp
5food: olive oil
6note: plus more for frying

Its most important rule is one input ingredient in, one structured ingredient out, in exactly the same order. Never merge, split, remove, reorder or invent ingredients. If a fragment is uncertain, preserve it in the note instead of confidently throwing it into the void.

The rest handles vulgar fractions, mixed numbers, written numbers, ranges, grouped quantities such as 2 dozen, canonical units, preparation notes, multilingual grammar and ambiguous tokens. It explicitly separates recognition from output: the model may understand tablespoons, tbs and tbsp, but the stored unit must be exactly tbsp.

This distinction sounds fussy until shopping-list aggregation meets tablespoon, tablespoons, tbsp and T. Normalization is how four aliases stop pretending to be four pantry items.

The two prompts deliberately have different language policies. Whole-recipe scraping translates non-English recipes into English. Ingredient parsing preserves its source language unless translation was explicitly requested, because it may be invoked independently on an existing recipe. Context matters. Apparently even dinner needs an API contract.

The prompts did not work perfectly on the first attempt because nothing works perfectly on the first attempt except rm -rf, and that works much too well.

I tested against recipes from different sites, languages and formatting traditions. Then I inspected what Mealie actually stored, found a new species of nonsense, tightened an invariant, and tried again. The deployment history records eight prompt-and-scraper refinements on July 13 alone.

Early versions said “convert to metric” but did not constrain the final vocabulary, so long unit names still leaked through. Then I required abbreviations. The model produced heading-only instruction steps, so the prompt gained action tests and merge rules. Images needed stricter selection. Counts needed protection from over-normalization. Translation needed an explicit boundary around culturally meaningful names. Ingredient parsing needed stronger lossless guarantees.

Prompt engineering, at least the useful kind, looks less like wizardry and more like property-based testing conducted by an increasingly opinionated cook:

1invariant: ingredient_count(output) == ingredient_count(input)
2invariant: every instruction contains a cooking action
3invariant: Fahrenheit does not survive normalization
4invariant: supported metric units belong to {mg, g, kg, ml, l}
5invariant: culturally meaningful names are not translated into oatmeal

The goal was not to describe every possible recipe on Earth. The goal was to identify failures I actually observed and convert them into rules precise enough to test on the next import.

The bill, featuring an anticlimactic amount of capitalism

OpenAI platform usage after prompt testing and importing roughly thirty recipes: 360,724 tokens, 73 requests and $0.73 spent.

After all the prompt testing and importing roughly thirty recipes, the OpenAI platform reported:

  • 73 requests
  • 360,724 total tokens
  • $0.73 spent
  • 0 blocked requests

That is about one US cent per request across the entire messy test session, or roughly two and a half cents per finished recipe if I unfairly charge all prompt-development traffic to the thirty recipes. Future imports should be cheaper because I am no longer repeatedly asking the model whether millilitres means ml and then updating a text file when it gets creative.

With the same token mix, Terra would have cost about $1.83 and Sol about $3.65. None of those totals would endanger the household economy, but cost discipline is a habit. The correct question is not “can I afford the flagship?” It is “does the flagship improve this workload enough to justify five times the price?”

For parsing recipes, Luna’s answer was already good. Sol cannot make 190°C more Celsius.

The aha moment

The original scraper and the model are not really competitors. They solve different layers of the problem.

recipe-scrapers extracts what a page says. Luna applies a house policy to what it means. Mealie’s existing cleaner then converts the result into application data. The small Python patch merely changes which specialist speaks first.

That layering is why the result feels much better than replacing everything with “AI.” Deterministic code still fetches pages, controls the strategy pipeline, validates schema-shaped output, cleans fields and stores data. The model handles the fuzzy boundary where language, culture and inconsistent measurements make rigid parsers miserable.

The lesson is not that every scraper needs an LLM. Most do not. If you need faithful extraction from known sites, recipe-scrapers is faster, cheaper and more predictable. My requirement was different: translate, normalize, preserve meaning, reconcile repeated values, and enforce a personal editorial standard across many sources. That is precisely where a language model earns its tiny invoice.

The post-mortem

My Mealie library now contains food from the places I wanted to explore, with English instructions, sensible metric quantities, Celsius temperatures, useful images and ingredients that can participate in shopping lists without developing multiple identities.

The final system is wonderfully disproportionate:

  • Mealie runs as a single restricted Kubernetes pod.
  • CloudNativePG stores the database.
  • Authentik handles OIDC.
  • OpenTofu mounts custom prompts and one patched Python module through ConfigMaps.
  • GPT-5.6 Luna turns hostile food-blog archaeology into a normalized JSON representation of schema.org Recipe data.
  • The regular scraper remains available when the AI path cannot help.

Would a notebook have been simpler? Yes. A notebook also refuses to translate a Serbian recipe, convert the oven temperature, build a shopping list and roll itself after a prompt checksum changes. Checkmate, paper.

The patch is small. The prompts are not. That is the real shape of this project: six rearranged lines of Python opened the route, but most of the engineering went into defining what “a clean recipe” actually means.

If you try something similar, start with the cheapest capable model, test on hostile real-world inputs, write down invariants instead of vibes, and keep deterministic validation on both sides of the model. Also leave yourself a very loud upgrade warning when you mount a ConfigMap over somebody else’s Python package.

Future you will already be maintaining Kubernetes for a recipe manager. Be kind to her.


back to all field notes
NORMAL ~/blog
--:--:--