
Ethan Collins
Pattern Recognition Specialist

用于AI订餐系统的餐厅菜单数据应作为来源优先的数据产品构建,而不是菜品名称和价格的集合。最可靠的管道从所有者控制的源开始,如Google Business Profile食品菜单、POS菜单API、导出和第一方结构化数据。然后将地点、菜单、部分、项目、选项、价格、货币、语言、饮食标签、过敏原、可用性和观察时间归一化为稳定模式。每个字段必须保持可追溯到其来源,尤其是在AI系统回答问题、比较选项或准备订单时。渲染页面收集是较低优先级的备用方案,只能在获得授权、速率限制和严格挑战处理的情况下运行。本指南展示了如何从摄入到验证和审核设计该管道。
一家餐厅很少有一份永恒的菜单。数据可能因以下原因而异:
一个将这些差异简化的AI订餐系统可能会报价错误的价格,遗漏必需的选项或错误陈述饮食信息。
CapSolver网页抓取博客提供了相关的提取指导,而网页抓取常见问题解答解释了源和操作注意事项。
从最权威和结构化的源开始。
| 优先级 | 源 | 优势 | 主要控制 |
|---|---|---|---|
| 1 | 所有者或POS API | 结构化且权威 | 身份验证和合同范围 |
| 2 | 所有者导出或馈送 | 稳定的批量摄入 | 版本和新鲜度元数据 |
| 3 | 商业资料菜单API | 结构化的位置级菜单数据 | 账户和位置授权 |
| 4 | 第一方JSON-LD或嵌入数据 | 公开且机器可读 | 模式验证和源URL |
| 5 | 授权的渲染页面 | 当没有馈送时有用 | 速率限制、页面状态、证据 |
| 6 | OCR或图像解析 | 最后手段 | 低置信度和强制审查 |
不要将第三方聚合器视为与餐厅自身菜单等同。
Google Business Profile的FoodMenus模型定义了菜单、部分、项目、标签、选项、价格、菜系、过敏原、饮食限制、营养、成分、准备方法、份量大小和媒体键。
Google的食品菜单更新指南还记录了地点资格和所有者控制的读取和更新流程。
Schema.org的Menu类型描述了具有hasMenuSection和hasMenuItem关系的结构化菜单。
Toast的Menus API指南建议在检索菜单前检查元数据以确定缓存数据是否过时。
这些模型支持一个共同的设计:保留层次结构和新鲜度,而不是将所有内容扁平化为一个文本块。
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
将源证据附加到每条记录。当单个项目来自不同源时,仅菜单级时间戳是不够的。
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
菜单ID应代表特定的位置和上下文。在没有明确规则的情况下,不要将午餐和晚餐价格或外卖和堂食菜单合并。
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
在可用时使用条件请求、元数据端点、ETags或源修改时间。避免重复下载未更改的菜单。
菜单价格归一化应保留原始表示。
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
永远不要将“市场价格”转换为零以供下游显示。信标应与真实数值价格区分开。
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] = []
订购系统需要选择约束,而不仅仅是选项名称。“选择一个尺寸”和“最多选择三个配料”是不同的规则。
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]
不要用生成的翻译覆盖原始文本。保留源语言、翻译标志、模型版本和审核状态。
Google的FoodMenus模型支持过敏原和饮食限制,但下游系统应仅展示源支持的声明。
美国食品药品监督管理局的食品过敏指南说明了为什么过敏原信息与安全相关。
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
)
不要从成分、菜系、图像或AI模型的假设中推断“无坚果”、“无麸质”、“清真”、“犹太洁食”等声明。
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
验证模式并为每个归一化记录保留源URL、观察时间和内容哈希。
当授权餐厅未发布API、馈送或结构化数据时,可能需要使用渲染页面收集。在导航之前:
CapSolver法律网页抓取指南提供了额外的合规上下文。
在授权收集运行期间,第一方菜单页面可能会出现支持的挑战。CapSolver可以在API和结构化数据选项耗尽后,适合此狭窄的管道部分。
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",
}
)
CapSolver产品页面有助于确认支持的任务家族。永远不要将挑战解释为缺少菜单数据。
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
账本使审核者能够回答价格来自何处、何时观察到以及哪个管道版本生成了它。
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
这些是示例内部策略,不是普遍事实。根据源行为、合同条款、用户期望和风险设置限制。
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
不要对排序差异或仅空白更改发出警报。比较标准化记录。
AI层应从经过验证的记录中回答,且在没有单独确认步骤的情况下永远不要下订单。
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
)
在要求用户明确确认之前,显示位置、项目、选项、数量、小计、费用、税款、来源时间以及取消条款。
| 流水线设计 | 来源质量 | 新鲜度 | 安全性 | 推荐 |
|---|---|---|---|---|
| 平面化页面文本 | 低 | 未知 | 低 | 避免 |
| 第三方聚合 | 可变 | 可变 | 中等 | 谨慎使用 |
| 所有者API加上标准化账本 | 高 | 高 | 高 | 优先选择 |
| 第一方页面回退并经过审查 | 中等 | 可衡量 | 中等到高 | 在授权时使用 |
首选架构从所有者控制的结构化来源开始,并将证据附加到每个字段。
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)
对失败的记录进行隔离,而不是用生成的文本静默修复它们。
跟踪:
不要记录API密钥、cookies、私人客户数据、支付数据或完整的浏览器存储状态。
CapSolver AI和自动化常见问题解答 可以支持工具和审批边界设计。
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
还要测试货币精度、位置分离、多语言标签、选项约束、过时价格、重复的项目ID、来源优先级、挑战页面分类和订单确认门禁。
附加代码:在 CapSolver仪表板 上使用代码 WEBS 可以在每次充值时获得额外5%的奖励。
CapSolver CAPTCHA求解常见问题解答 为授权的挑战步骤提供了额外指导。
仅从所有者控制、许可、公开或明确授权的来源收集餐厅菜单数据。遵守API协议、条款、robots指令、速率限制、版权、隐私和数据库权利。不要收集客户、支付、忠诚度或私人订单数据。不要推断过敏原或饮食适宜性。AI系统应展示来源时间和不确定性,且重要决策应由人类确认。
AI订购系统的餐厅菜单数据需要层次结构、来源、新鲜度和安全控制。优先使用所有者API和POS馈送,保留位置和菜单上下文,标准化项目选项和价格,保持语言变体关联,并对过敏原和饮食标签要求明确证据。仅在受控回退情况下使用第一方渲染页面,并将支持的挑战处理视为一个狭窄的基础设施步骤——而不是访问来源的许可。
在支持的挑战中断批准的菜单收集时,使用 CapSolver 构建授权流水线,然后在AI系统使用结果之前验证来源、菜单状态、字段来源和新鲜度。
首先使用所有者授权的API、POS馈送或导出。业务资料菜单和第一方结构化数据是有用的次级来源。
包括餐厅、位置、菜单、部分、项目、选项、价格、货币、语言、饮食标签、过敏原、可用性、来源和观察时间。
不应该。只有在来源明确陈述且满足相关审查政策时才呈现这些声明。
设置字段特定的策略。可用性和价格通常需要比描述或媒体更短的限制,但具体间隔取决于来源行为和风险。
当没有批准的结构化来源时,CapSolver可以在授权的第一方页面上处理支持的挑战。结果仍需要页面和数据验证。