Side Project · Accessibility

big-recipes

The sibling to big-ads — accessible recipe search, reading, listening, and printing. Where big-ads could only magnify an image, this one owns the text, so typography and audio became design surfaces.

Deployed in production v0.1.7 · built in a two-day sprint Source available on request

big-ads solved one problem for one person: weekly grocery flyers, unreadable on retailer sites. big-recipes is for the same person and the same impairment, but it is not the same project — and the spec is precise about why:

big-ads works with images, big-recipes works with text. A flyer is a JPEG and all you can do is magnify it. A recipe is structured data, so typography, audio, and print layout are all fully under our control.

That single difference changes everything downstream. With a flyer, the ceiling is “make it bigger.” With a recipe, you can set the type, read it aloud, step through it hands-free, and print it — but only if you can first get clean structured data out of a page that wasn’t built to give it to you.


Why recipe sites are their own kind of hostile

A modern recipe page is mostly not the recipe. It’s an autoplaying video, a life story, three ad breaks, a newsletter modal, and a “jump to recipe” button that sometimes works. For someone running heavy screen magnification, that isn’t an annoyance — it’s a wall.

There’s a second failure that’s worse and less obvious. Search a dish and the results are dominated by roundups: “35 Easy Soup Recipes.” A roundup is not a recipe. Clicking one and discovering that costs a sighted person two seconds; it costs this user a genuinely frustrating minute.

So the design goal was never “display recipes nicely.” It was: every result on the screen must be a real, readable recipe.


The hard part: getting a usable recipe out of a hostile page

The system searches a self-hosted SearXNG across three engines, takes roughly eighteen candidates, and runs each through a three-tier extractor:

  1. schema.org Recipe JSON-LD, walked recursively including @graph containers. Fast and exact when it’s there.
  2. recipe-scrapers — around five hundred site-specific handlers.
  3. Headless Chromium, for pages that don’t serve structured data to a plain HTTP client.

Tier 3 is expensive, so it had to justify itself with a number. Across sixteen dish queries, tiers 1–2 filled every slot on 10 of 16 shortlists. All three tiers filled 16 of 16. That’s the entire argument for carrying a browser in the image.

Extract before display

The decision I’d point at first: extraction runs on candidates before the shortlist renders. Anything that fails extraction is silently dropped and never appears as a card.

That inverts the normal cost model. Conventionally you show ten results fast and let the user discover which are duds. Here the user pays a few seconds up front, and in exchange the spec’s requirement holds:

He cannot click a dud.

Roundups get demoted before that, by matching URL shapes (/gallery/, /g1234/) and title patterns (“35 Easy Soup Recipes”) — a rule written because the query “easy soup recipe” came back with twelve roundups and zero actual recipes.


Making it fast enough to be usable

The first implementation fetched candidates in waves of six with asyncio.gather. Measured against the live cluster, that was bad: 19 of 54 candidates failed and burned 62 seconds, with a single slow host sitting on its timeout for 25.4 seconds alone. Every wave moved at the speed of its worst member.

The rewrite uses asyncio.as_completed with a semaphore, and three details matter more than the swap itself:

Learning which hosts to trust

Every fetch updates a per-host success rate, beta-smoothed against a prior so one bad day doesn’t condemn a site. Those rates bucket into three deliberately coarse tiers — coarse on purpose, so a marginally more reliable host can never outrank a far more relevant result.

Hosts that have never once succeeded after at least five attempts get skipped entirely, with a 30-day probation so a site that later grows a Recipe schema comes back on its own without intervention. The same table remembers which hosts need the browser tier, which previously had to be relearned on every restart.

GET /api/hosts exposes the whole table, on the principle that a ranking which silently drops sites is one nobody can argue with. The reputation module is pure policy — no database, no network — so the rules are unit-testable in isolation.


Accessibility, in the code

Every constraint from big-ads carries over, and the text medium adds more:

Same rules as before: no build step, no framework, no npm, no modals, no hover-only affordances.


Deployment, and the one place L7 mattered

Delivery is the same GitOps pipeline big-ads uses — gitleaks, pytest, build, registry push, manifest patch, ArgoCD reconcile — with a CI step that fails hard if the image-tag patch produces no diff.

The runtime posture is deliberately tight: a dedicated ServiceAccount with no role bindings and no mounted token, readOnlyRootFilesystem, non-root, all capabilities dropped, seccompProfile: RuntimeDefault, and Istio deny-all plus allow-ingress.

Egress is the interesting exception. Every other namespace on the platform gets either a no-internet lane or an FQDN allowlist (ADR 008). This one can’t — a recipe search fetches arbitrary sites by definition, so the destination set is unknowable in advance. It gets a port-constrained lane instead: broad by necessity, and narrowed on the axis that’s still available.

It’s also the only service on the platform split across two hostnames with different capabilities — a public read-only surface and a private one that can write, export, and inspect. That split is enforced at the Istio VirtualService rather than in an AuthorizationPolicy, and the manifests are emphatic about why: the namespace is ambient, ztunnel is L4-only, and no waypoint proxy is deployed (ADR 009). A policy carrying paths or methods would be accepted, look like a control, and enforce nothing.

That’s the kind of failure worth designing against — not a control that breaks loudly, but one that silently isn’t there.


The decision to not use the better-sounding architecture

The original spec called for CloudNativePG and MinIO — the platform’s standard, operator-managed, properly backed-up storage. It shipped instead on SQLite with FTS5 and content-addressed gzipped blobs on a PVC.

That looks like a downgrade, and I nearly built the impressive version. The measurement stopped it: the archive is meant to be permanent, and the platform’s database and object-store retention is fourteen days. Porting would have moved a permanent archive from one bounded retention window to a slightly larger one, while adding two runtime dependencies to an application whose entire premise is working when the internet doesn’t.

The gap is a retention policy problem, not a storage-engine one.

Durability comes instead from a monthly export job that writes the archive to a never-expiring MinIO bucket — solving retention where retention actually lives, and leaving the app with one dependency instead of three.


Engineering summary

Honest caveats

This was a two-day build. Thirty-one commits across two days, and little maintenance since. big-ads earned “in weekly use” through months of OOM fixes and feedback-driven commits; big-recipes hasn’t earned that yet. It’s deployed and it works — it is not battle-tested.

Roughly half the spec isn’t built. Voice input, step timers, server-side PDF, direct printing, and self-hosted sentence-level audio are all designed and none are implemented. One endpoint exists that nothing calls.

Errors use alert(). The wording is plain-English and non-technical, but it’s the one place the interface falls back on a browser default instead of a designed control.


Why it’s here

big-ads is the one that gets used every week. big-recipes is the one that shows what happens when the same constraints meet a medium that gives you room to work.

The engineering I’d defend in a review is all in the measurements: carrying a headless browser because 10-of-16 became 16-of-16, rewriting concurrency because 62 seconds were going into fetches nobody would ever see, refusing to punish a host for losing a race it didn’t need to win, and turning down the more impressive storage architecture because it measured worse against the thing that actually mattered.

That last one is the habit I’d bring to a team: the fancier design was available, and the right call was still to leave it on the page.