Case study
Cookbook Press
Recipes saved off food blogs and TikTok, parsed by a model running at home. The library lives on the phone; the server stores nothing.
- Platform
- iOS
- Stack
- SwiftUI · SwiftData · FastAPI · a local LLM
- Status
- Builds and runs; API live
- Scale
- 2,000 lines Swift · 1,150 Python · 69 validated feeds
The problem
Recipes live on food blogs wrapped in a thousand words of preamble, and increasingly on TikTok where they live in a caption and a voiceover. Saving one means either a bookmark that rots or copying it out by hand. The existing recipe managers either want a subscription or send every page you read to someone else’s server.
The decision worth defending
Deterministic first, model second
The obvious build is “throw the page at an LLM.” That is worse in every measurable way for the majority of pages, and the measurement is in the repo: about 58% of the catalog publishes schema.org recipe data, which gives exact title, times, yield, ingredient lines and steps.
So the pipeline is ordered. Structured data if the page has it — strictly better than inference. The model then splits ingredient lines and picks a category from that structured data. Only pages with no structured data at all get read by the model as prose. The model is used for the two things parsing genuinely cannot do, and everything else is parsing.
This has a second benefit that matters more than it sounds: the deterministic path can be exercised against live pages with the model server switched off. Most of the extraction logic is testable without a GPU in the loop.
Decisions
-
A model that returns a different ingredient count than it was given is rejected
The structured lines are ground truth. A silently merged or dropped ingredient produces a recipe that looks fine and cooks wrong; an unsplit list is visibly less useful but never lies. The split is discarded and the recipe saves with a warning.
-
Every ingredient keeps its raw line
The original text as the site wrote it is stored alongside the parse, so a bad split is recoverable rather than destructive.
-
Categories are a fixed enum of ten
The model must choose from it. A free-text category would fragment the library into “Dessert”, “Desserts” and “Sweets” inside a week.
-
The share extension is the product
Safari runs a content script first and hands the extension the live DOM, which defeats the bot walls that would refuse a server-side fetch of the same URL. A large fraction of food blogs will not serve their page to a datacentre — and the phone is already logged in and already trusted.
-
Feeds are proven rather than trusted
Nothing gets into the source list without a live fetch proving it parses and carries items, using the same user agent the running server uses — so a feed that validates also works in production.
Privacy posture, stated plainly
The server is stateless: it fetches, extracts, returns. There is no database, no user record, no history. The library is on the phone. The model runs on a machine in the house.
The honest caveat is documented rather than hidden: a share extension cannot complete an interactive login, so there is no access proxy in front of the API. A shared key is the only guard between a public hostname and that machine’s GPU. That is a considered trade for a single-user app, and it is the one secret that must not leak.
Engineering notes
-
Relationship arrays do not preserve order
Ingredient order is carried by an explicit field; reading the relationship directly scrambles the method. This is the kind of bug that produces a recipe which is subtly, deniably wrong.
-
Two processes, one schema
The share extension compiles the app’s models, services and design directories into its own target rather than duplicating them.
-
A project name saved another project
The compose project name is pinned, because it otherwise takes the name of its directory — which already belonged to a different live stack on the same host. Sharing the name would let a teardown here stop another app’s backend.
Where it stands
The app builds and runs, the API is live behind a tunnel, and the extraction path is exercised against live pages by two tools in the repo.
Screens