
Ethan Collins
Pattern Recognition Specialist

Restaurant menu data for AI ordering systems should be built as a provenance-first data product, not as a collection of dish names and prices. The most reliable pipeline starts with owner-controlled sources such as Google Business Profile Food Menus, POS menu APIs, exports, and first-party structured data. It then normalizes location, menu, section, item, option, price, currency, language, dietary labels, allergens, availability, and observation time into a stable schema. Each field must remain traceable to its source, especially when an AI system answers questions, compares options, or prepares an order. Rendered-page collection is a lower-priority fallback and should operate only with permission, rate limits, and strict challenge handling. This guide shows how to design that pipeline from ingestion through validation and review.
A restaurant rarely has one timeless menu. The data can vary by:
An AI ordering system that flattens these differences can quote the wrong price, omit a required option, or misstate dietary information.
The CapSolver web-scraping blog provides related extraction guidance, while the web-scraping FAQ explains source and operational considerations.
Start with the most authoritative and structured source.
| Priority | Source | Strength | Main control |
|---|---|---|---|
| 1 | Owner or POS API | Structured and authoritative | Authentication and contract scope |
| 2 | Owner export or feed | Stable batch ingestion | Version and freshness metadata |
| 3 | Business Profile menu API | Structured location-level menu data | Account and location authorization |
| 4 | First-party JSON-LD or embedded data | Public and machine-readable | Schema validation and source URL |
| 5 | Authorized rendered page | Useful when no feed exists | Rate limits, page state, evidence |
| 6 | OCR or image parsing | Last resort | Low confidence and mandatory review |
Do not treat third-party aggregators as equivalent to the restaurant's own menu.
Google Business Profile's FoodMenus model defines menus, sections, items, labels, options, price, cuisine, allergens, dietary restrictions, nutrition, ingredients, preparation methods, portion size, and media keys.
Google's food-menu update guide also documents location eligibility and the owner-controlled read-and-update flow.
Schema.org's Menu type describes a structured menu with hasMenuSection and hasMenuItem relationships.
Toast's Menus API guide recommends checking metadata before retrieving menus to determine whether cached data is stale.
These models support a common design: preserve hierarchy and freshness instead of flattening everything into one text block.
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field, HttpUrl
class Money(BaseModel):
currency: str = Field(min_length=3, max_length=3)
amount: Decimal
tax_included: bool | None = None
class Evidence(BaseModel):
source_type: Literal[
"OWNER_API",
"OWNER_EXPORT",
"BUSINESS_PROFILE",
"JSON_LD",
"AUTHORIZED_PAGE",
"IMAGE_REVIEW",
]
source_url: HttpUrl | None = None
source_record_id: str | None = None
observed_at: datetime
source_modified_at: datetime | None = None
content_hash: str
language: str
class MenuOption(BaseModel):
option_id: str
name: str
price_delta: Money | None = None
available: bool | None = None
class MenuItem(BaseModel):
item_id: str
restaurant_id: str
location_id: str
menu_id: str
section_id: str
name: str
description: str | None = None
base_price: Money | None = None
options: list[MenuOption] = []
dietary_labels: list[str] = []
allergens: list[str] = []
ingredients: list[str] = []
available: bool | None = None
evidence: Evidence
Keep source evidence attached to each record. A menu-level timestamp is not enough when individual items come from different sources.
class Restaurant(BaseModel):
restaurant_id: str
brand_name: str
legal_name: str | None = None
class Location(BaseModel):
location_id: str
restaurant_id: str
address_line: str
city: str
region: str | None = None
postal_code: str | None = None
country_code: str
timezone: str
class Menu(BaseModel):
menu_id: str
location_id: str
name: str
service_modes: list[str]
dayparts: list[str]
valid_from: datetime | None = None
valid_until: datetime | None = None
language: str
A menu ID should represent a specific location and context. Do not combine lunch and dinner prices or merge delivery and dine-in menus without explicit rules.
from hashlib import sha256
import json
def canonical_hash(payload: dict) -> str:
encoded = json.dumps(
payload,
sort_keys=True,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
return sha256(encoded).hexdigest()
async def ingest_owner_menu(api, location_id: str) -> list[MenuItem]:
metadata = await api.get_menu_metadata(location_id)
if metadata.not_modified:
return []
payload = await api.get_menu(location_id)
observed_at = utc_now()
records = []
for menu in payload["menus"]:
for section in menu.get("sections", []):
for item in section.get("items", []):
records.append(
normalize_owner_item(
location_id=location_id,
menu=menu,
section=section,
item=item,
observed_at=observed_at,
content_hash=canonical_hash(item),
)
)
return records
Use conditional requests, metadata endpoints, ETags, or source modification times when available. Avoid downloading an unchanged menu repeatedly.
Menu price normalization should preserve the original representation.
class NormalizedPrice(BaseModel):
amount: Decimal
currency: str
original_text: str | None = None
price_type: Literal[
"FIXED",
"FROM",
"RANGE",
"MARKET_PRICE",
"INCLUDED",
"UNKNOWN",
]
upper_amount: Decimal | None = None
def normalize_price(raw: dict, currency: str) -> NormalizedPrice | None:
if raw.get("market_price"):
return NormalizedPrice(
amount=Decimal("0"),
currency=currency,
original_text=raw.get("display"),
price_type="MARKET_PRICE",
)
if raw.get("min") is not None and raw.get("max") is not None:
return NormalizedPrice(
amount=Decimal(str(raw["min"])),
upper_amount=Decimal(str(raw["max"])),
currency=currency,
original_text=raw.get("display"),
price_type="RANGE",
)
if raw.get("amount") is not None:
return NormalizedPrice(
amount=Decimal(str(raw["amount"])),
currency=currency,
original_text=raw.get("display"),
price_type="FIXED",
)
return None
Never convert “market price” into zero for downstream display. The sentinel should remain distinct from a real numeric price.
class ModifierChoice(BaseModel):
choice_id: str
name: str
price_delta: Money | None = None
available: bool | None = None
class ModifierGroup(BaseModel):
group_id: str
name: str
minimum_selections: int
maximum_selections: int
choices: list[ModifierChoice]
class OrderableItem(MenuItem):
modifier_groups: list[ModifierGroup] = []
An ordering system needs selection constraints, not just option names. “Choose one size” and “choose up to three toppings” are different rules.
class LocalizedText(BaseModel):
language: str
value: str
source_value: str
translated: bool = False
translation_model: str | None = None
class LocalizedMenuItem(BaseModel):
item_id: str
names: list[LocalizedText]
descriptions: list[LocalizedText]
Do not overwrite original text with a generated translation. Preserve the source language, translated flag, model version, and review status.
Google's FoodMenus model supports allergens and dietary restrictions, but downstream systems should only present claims supported by the source.
The U.S. Food and Drug Administration's food-allergy guidance illustrates why allergen information is safety-relevant.
class SafetyClaim(BaseModel):
claim: str
source_type: str
explicit_source_text: str
confidence: float
reviewed: bool
reviewer_id: str | None = None
def allow_safety_claim(claim: SafetyClaim) -> bool:
return (
claim.source_type in {"OWNER_API", "OWNER_EXPORT", "BUSINESS_PROFILE"}
and bool(claim.explicit_source_text.strip())
and claim.reviewed
)
Do not infer “nut-free,” “gluten-free,” “halal,” “kosher,” or similar claims from ingredients, cuisine, images, or an AI model's assumptions.
import json
from bs4 import BeautifulSoup
def extract_json_ld_menu(html: str) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
menus = []
for script in soup.select('script[type="application/ld+json"]'):
try:
payload = json.loads(script.string or "")
except json.JSONDecodeError:
continue
nodes = payload if isinstance(payload, list) else [payload]
for node in nodes:
if not isinstance(node, dict):
continue
if node.get("@type") == "Menu":
menus.append(node)
graph = node.get("@graph", [])
menus.extend(
entry
for entry in graph
if isinstance(entry, dict) and entry.get("@type") == "Menu"
)
return menus
Validate the schema and keep the source URL, observed time, and content hash with each normalized record.
Rendered-page collection may be necessary when an authorized restaurant publishes no API, feed, or structured data. Before navigating:
The CapSolver legal web-scraping guide provides additional compliance context.
A first-party menu page may present a supported challenge during an authorized collection run. CapSolver can fit this narrow part of the pipeline after API and structured-data options are exhausted.
class CollectionDecision(BaseModel):
source_type: str
authorized: bool
public_fields_only: bool
challenge_type: str | None = None
rate_limit_ok: bool
def may_use_challenge_service(decision: CollectionDecision) -> bool:
return (
decision.source_type == "AUTHORIZED_PAGE"
and decision.authorized
and decision.public_fields_only
and decision.rate_limit_ok
and decision.challenge_type in {
"RECAPTCHA_V2",
"RECAPTCHA_V3",
"CLOUDFLARE_TURNSTILE",
"CLOUDFLARE_CHALLENGE",
}
)
The CapSolver products page helps confirm supported task families. Never interpret a challenge as missing menu data.
class SourceLedgerEntry(BaseModel):
run_id: str
restaurant_id: str
location_id: str
source_type: str
source_url: str | None
permission_basis: str
observed_at: datetime
source_modified_at: datetime | None
record_count: int
content_hash: str
challenge_observed: bool
human_review_required: bool
The ledger lets a reviewer answer where a price came from, when it was observed, and which pipeline version produced it.
from datetime import timedelta
FRESHNESS = {
"availability": timedelta(minutes=15),
"price": timedelta(hours=6),
"description": timedelta(days=7),
"dietary_labels": timedelta(days=7),
"allergens": timedelta(days=1),
"media": timedelta(days=30),
}
def is_fresh(field: str, observed_at: datetime, now: datetime) -> bool:
maximum_age = FRESHNESS[field]
return now - observed_at <= maximum_age
These are example internal policies, not universal facts. Set limits according to source behavior, contract terms, user expectations, and risk.
class MenuChange(BaseModel):
restaurant_id: str
location_id: str
item_id: str
field: str
before: object
after: object
source_before: str
source_after: str
requires_review: bool
HIGH_RISK_FIELDS = {"allergens", "dietary_labels", "availability", "price"}
def compare_items(previous: MenuItem, current: MenuItem) -> list[MenuChange]:
changes = []
for field in [
"name",
"description",
"base_price",
"options",
"dietary_labels",
"allergens",
"available",
]:
before = getattr(previous, field)
after = getattr(current, field)
if before != after:
changes.append(
MenuChange(
restaurant_id=current.restaurant_id,
location_id=current.location_id,
item_id=current.item_id,
field=field,
before=before,
after=after,
source_before=previous.evidence.content_hash,
source_after=current.evidence.content_hash,
requires_review=field in HIGH_RISK_FIELDS,
)
)
return changes
Do not alert on ordering differences or whitespace-only changes. Compare normalized records.
The AI layer should answer from verified records and never place an order without a separate confirmation step.
class OrderProposal(BaseModel):
location_id: str
item_id: str
option_ids: list[str]
quoted_total: Money
menu_observed_at: datetime
source_record_id: str
safety_claims_reviewed: bool
def may_present_for_confirmation(proposal: OrderProposal, now: datetime) -> bool:
return (
is_fresh("price", proposal.menu_observed_at, now)
and proposal.safety_claims_reviewed
and proposal.quoted_total.amount >= 0
)
Present the location, item, options, quantity, subtotal, fees, taxes, source time, and cancellation terms before asking for explicit user confirmation.
| Pipeline design | Source quality | Freshness | Safety | Recommendation |
|---|---|---|---|---|
| Flattened page text | Low | Unknown | Low | Avoid |
| Third-party aggregate only | Variable | Variable | Medium | Use cautiously |
| Owner APIs plus normalized ledger | High | High | High | Preferred |
| First-party page fallback with review | Medium | Measurable | Medium to high | Use when authorized |
The preferred architecture starts with owner-controlled structured sources and keeps evidence attached to each field.
class QualityResult(BaseModel):
accepted: bool
reasons: list[str]
def validate_item(item: MenuItem) -> QualityResult:
reasons = []
if not item.name.strip():
reasons.append("missing_name")
if item.base_price and item.base_price.amount < 0:
reasons.append("negative_price")
if item.evidence.source_type == "IMAGE_REVIEW":
reasons.append("image_source_requires_review")
if item.allergens and item.evidence.source_type not in {
"OWNER_API",
"OWNER_EXPORT",
"BUSINESS_PROFILE",
}:
reasons.append("allergen_source_requires_review")
return QualityResult(accepted=not reasons, reasons=reasons)
Quarantine failed records instead of silently repairing them with generated text.
Track:
Do not log API keys, cookies, private customer data, payment data, or full browser storage state.
The CapSolver AI and automation FAQ can support tool and approval-boundary design.
import pytest
def test_market_price_is_not_zero_price():
value = normalize_price(
{"market_price": True, "display": "Market price"},
"USD",
)
assert value.price_type == "MARKET_PRICE"
assert value.original_text == "Market price"
def test_unreviewed_allergen_claim_is_blocked():
claim = SafetyClaim(
claim="contains peanuts",
source_type="JSON_LD",
explicit_source_text="contains peanuts",
confidence=1.0,
reviewed=False,
reviewer_id=None,
)
assert allow_safety_claim(claim) is False
Also test currency precision, location separation, multilingual labels, option constraints, stale prices, duplicated item IDs, source priority, challenge-page classification, and order-confirmation gates.
Bonus Code: Use code WEBS at CapSolver Dashboard to get an extra 5% bonus on every recharge.
The CapSolver CAPTCHA-solving FAQ provides additional guidance for authorized challenge steps.
Collect restaurant menu data only from owner-controlled, licensed, public, or explicitly authorized sources. Respect API agreements, terms, robots directives, rate limits, copyright, privacy, and database rights. Do not collect customer, payment, loyalty, or private order data. Do not infer allergens or dietary suitability. An AI system should present source time and uncertainty, and a human should confirm consequential choices.
Restaurant menu data for AI ordering systems needs hierarchy, provenance, freshness, and safety controls. Prefer owner APIs and POS feeds, preserve location and menu context, normalize item options and prices, keep language variants linked, and require explicit evidence for allergens and dietary labels. Use authorized rendered pages only as a controlled fallback, and treat supported challenge handling as one narrow infrastructure step—not as permission to access a source.
Build an authorized pipeline with CapSolver where supported challenges interrupt approved menu collection, then verify source, menu state, field provenance, and freshness before an AI system uses the result.
Use an owner-authorized API, POS feed, or export first. Business Profile menus and first-party structured data are useful secondary sources.
Include restaurant, location, menu, section, item, option, price, currency, language, dietary labels, allergens, availability, source, and observation time.
It should not. Present those claims only when the source states them explicitly and the relevant review policy is satisfied.
Set field-specific policies. Availability and prices usually need shorter limits than descriptions or media, but the exact interval depends on source behavior and risk.
CapSolver can handle a supported challenge on an authorized first-party page when no approved structured source is available. The result still needs page and data validation.
Learn scalable Rust web scraping architecture with reqwest, scraper, async scraping, headless browser scraping, proxy rotation, and compliant CAPTCHA handling.

Learn the best techniques to scrape job listings without getting blocked. Master Indeed scraping, Google Jobs API, and web scraping API with CapSolver.
