📄

Finding menus for the venues that have none

Private deliverable. Enter the hub password to continue.

That's not right. Try again.

← Back to the hub

Finding menus for the venues that have none

Written 2026-08-09. Measured against data/boulder.db and the published src/data/venues.json, not estimated.

Where we actually stand

547 venues are published. 387 have at least one priced dish. 160 have none.

Prices reach the site from two sources, both already wired into export_dishes.py: the crawled menus in menu_items, and the merchant's own priced rows inside businesses.services (Google's copy). The second source is already being read, so there's no free win left sitting in the database. Every venue below needs new work.

Two facts shape the whole plan:

  • 133 of the 160 are local independents; 27 are chains. The local ones carry 13,454 review votes between them, so that's where the reader value is.
  • 35 of the 160 have no website at all. For those, the venue's own site cannot be the answer.

Why each one is missing, counted

bucket venues what happened
A 73 no menu URL was ever discovered
B 9 every menu URL we found failed to fetch
C 49 menu fetched fine, extraction returned zero items
D 29 items extracted, but the menu prints no prices

The existing menu_diagnosis table already explains bucket C, across the 160: not_a_menu 51, link_only 30, empty 25, blocked 17, js_required 4.

Bucket D is not a bug. Cosmo's Pizza, Sweet Cow, Spruce Confections, Lollicup and Rush Bowls all publish dish names with no prices on the page. Rush Bowls is a special case: its numbers were calorie counts and were correctly nulled by clean_calorie_prices.py. For these, the price lives on an ordering platform or nowhere public.

The plan, cheapest thing first

Each wave states what it costs, what it should return, and when to stop. Run them in order and re-measure after each one, because a venue fixed in wave 1 must not be worked again in wave 3.

Wave 0: free, no new network work (est. 10 to 20 venues)

Two things we already own and never used:

  1. 17 Google business_links menu URLs were never fetched at all. They're sitting in the table (Five Guys, Kung Fu Tea, Starbucks, Lil Red Coffee and others). Fetch them through the existing stage 7 path.
  2. 30 menus diagnosed link_only, meaning the page we downloaded is an index of links to the real menus, and we stopped there. Postino Boulder is the clearest example. Follow those one level deeper, which is exactly what stage 7 already does for site crawls, just never applied to this set.

Cost: $0 for the fetch, a few cents of extraction.

Wave 1: re-pull the Google listing for all 160 (est. 15 to 30 venues, ~$2)

The listings were scraped on 2026-08-03. Merchants add their menus to Google over time, and this is the single highest-yield-per-dollar move we have, because it needs no crawling, no rendering and no LLM. Roughly $0.0125 per venue by name, so about $2 for all 160.

Re-pull, then re-run export_dishes.py. Any venue that gains a priced services block is done.

Wave 2: fix the fetch failures properly (est. 10 to 20 venues, $0)

Covers bucket B (9) plus the 17 diagnosed blocked and 4 js_required.

Three rules, all learned the hard way and all currently violated:

  • Retry per URL, not per venue. The old headless pass selected venues with no successful menu, so a venue holding one good menu and five broken ones was never retried, and partial coverage froze silently.
  • Record why it failed. 101 rows currently say HTTPError and nothing else. A 403 wants a browser, a 404 wants re-discovery and a 429 wants a back-off, and right now they're indistinguishable. Store the status code, the Server header and any Retry-After.
  • Never classify with HEAD. Bot-protected hosts answer HEAD differently from GET, so a HEAD sweep produces a confident and wrong map of the damage. Re-probe with the same mechanism we'll actually use.

Wave 3: real discovery for the 41 bucket-A venues that have a website (est. 20 to 30 venues, under $1)

These have a site and we never found a menu on it. In order:

  1. Re-crawl the site two levels deep with the menu-word matcher, including footer and nav links, which is where "Menus" usually hides.
  2. If that misses, a targeted Tavily search per venue ("<name>" boulder menu) and take the venue's own domain or a known platform result.
  3. Check the ordering platforms by name: Toast, SpotOn, Olo, SinglePlatform, Popmenu, BentoBox. Render them headlessly and push the rendered text through the same LLM extraction path. Do not write per-platform parsers and do not call their private JSON APIs; their schemas change without notice and the WAF treats the two very differently.

Wave 4: the 32 with no website (est. 5 to 15 venues, ~$0.30)

Their menu, if it exists publicly, is a photo: on Facebook, on Instagram, or in the Google listing's own photo set, which we already hold in found_photos and review_images. Run the existing vision extraction over candidate menu-board images. Boulder food trucks and lounges (Lonchera Los Paisas, Velvet Elk, Tonic Alchemy) are the realistic wins here.

Wave 5: bucket D, where the menu genuinely has no prices (est. 5 to 10 venues)

21 local venues print dish names with no prices. The only honest price source is their ordering platform. Try it for the high-review ones (Cosmo's Pizza at 1,376 reviews, Sweet Cow at 955, Spruce Confections at 239) and accept that some restaurants simply don't publish prices.

What we deliberately will not chase

The 27 chains. McDonald's, Taco Bell, Wendy's, Panera, Subway, Starbucks and the rest set prices per franchise, so a national menu price would be wrong at this location, and a wrong price is worse than no price on a site whose entire claim is real menu prices. Leave them without prices and let the page say so.

The quality bar, so this doesn't get gamed

"Has at least one priced dish" is a vanity metric. A venue whose only extracted item is a $3 Diet Coke passes it and helps nobody. Count a venue as covered only when it has at least five priced items, at least one of which is a main. Report progress on that number, not on menus fetched.

Today, by that stricter bar: 236 venues have five or more priced items from crawled menus alone, before Google's rows are merged in.

Honest expected outcome

now after waves 0-3 after all five
published venues with prices 387 / 547 (71%) ~450 (82%) ~465 (85%)

The last 80 or so are structurally hard: no website, no Google menu, no ordering platform, or a deliberate choice not to publish prices. That's a real ceiling, not a to-do item, and the site should say "we could not confirm prices here" rather than leave the section blank.

Total cost

Under $5, dominated by the $2 listing re-pull. The 2026-08-02 extraction run did 1,825 menus for $0.89, so the LLM side of this is measured in cents.

Two settings that must be right before any extraction runs

Both cost real money and produced nothing the first time around:

  • Disable reasoning explicitly and set the token budget with headroom. A reasoning model once spent 2,779 of 3,000 tokens thinking and returned JSON truncated mid-object, so menus recorded as empty were simply cut off. Re-running the affected 957 added 11,976 dishes.
  • Catch exceptions inside the worker. An exception escaping a worker inside a ThreadPoolExecutor context blocks __exit__ on every remaining future while the process keeps issuing paid API calls and recording nothing.

Keep both env-gated: NO_REASONING, MAX_TOKENS, WORKERS.

Run order

py -3.13 scripts/stage7_menus.py        # waves 0 and 3 discovery + fetch
py -3.13 scripts/stage7b_menus_headless.py   # wave 2, retry per URL
py -3.13 scripts/stage1b_by_name.py     # wave 1, listing re-pull
py -3.13 scripts/stage8_menu_extract.py # extraction
py -3.13 scripts/export_dishes.py       # merge both sources, apply price sanity
py -3.13 scripts/export_site_data.py    # then ALWAYS the photo trio:
py -3.13 scripts/select_photos.py
py -3.13 scripts/stage5_fetch_photos.py

The last three run together, in that order, every time. export_site_data.py rebuilds venues.json from a database that holds no photos, so running it alone strips every hero image off the live site.

Published to Annette's hub. Rebuilt from the source markdown, so edit the source and rerun rather than editing this page.