
Aloísio Vítor
Image Processing Expert

Los datos de menú de restaurantes para sistemas de pedido de IA deben construirse como un producto de datos con prioridad a la proveniencia, no como una colección de nombres de platos y precios. El pipeline más confiable comienza con fuentes controladas por el propietario, como Google Business Profile Food Menus, APIs de menú de POS, exportaciones y datos estructurados de primera parte. Luego normaliza ubicación, menú, sección, elemento, opción, precio, moneda, idioma, etiquetas dietéticas, alérgenos, disponibilidad y hora de observación en un esquema estable. Cada campo debe mantenerse trazable a su fuente, especialmente cuando un sistema de IA responda preguntas, compare opciones o prepare un pedido. La recopilación de páginas renderizadas es un recurso de baja prioridad y debe operar solo con permiso, límites de tasa y manejo estricto de desafíos. Esta guía muestra cómo diseñar ese pipeline desde la ingesta hasta la validación y revisión.
Un restaurante rara vez tiene un menú único y eterno. Los datos pueden variar según:
Un sistema de pedido de IA que aplane estas diferencias puede citar un precio incorrecto, omitir una opción requerida o malinformar sobre información dietética.
El blog de web-scraping de CapSolver proporciona orientación relacionada sobre la extracción, mientras que la FAQ de web-scraping explica consideraciones de fuente y operativas.
Comience con la fuente más autoritativa y estructurada.
| Prioridad | Fuente | Fortaleza | Control principal |
|---|---|---|---|
| 1 | API del propietario o POS | Estructurada y autoritativa | Autenticación y alcance del contrato |
| 2 | Exportación o feed del propietario | Ingestión por lotes estable | Metadatos de versión y frescura |
| 3 | API de menú de perfil de negocio | Datos de menú a nivel de ubicación estructurado | Cuenta y autorización de ubicación |
| 4 | Datos JSON-LD o embebidos de primera parte | Públicos y legibles por máquinas | Validación de esquema y URL de origen |
| 5 | Página renderizada autorizada | Útil cuando no exista un feed | Límites de tasa, estado de página, evidencia |
| 6 | OCR o análisis de imágenes | Último recurso | Baja confianza y revisión obligatoria |
No trate a los agregadores de terceros como equivalentes al menú del propio restaurante.
El modelo FoodMenus de Google Business Profile define menús, secciones, elementos, etiquetas, opciones, precio, cocina, alérgenos, restricciones dietéticas, nutrición, ingredientes, métodos de preparación, tamaño de porción y claves de medios.
La guía de actualización de menú de comida de Google también documenta la elegibilidad de ubicaciones y el flujo de lectura y actualización controlado por el propietario.
El tipo Menu de Schema.org describe un menú estructurado con relaciones hasMenuSection y hasMenuItem.
La guía de la API de Menus de Toast recomienda revisar los metadatos antes de recuperar menús para determinar si los datos en caché están obsoletos.
Estos modelos respaldan un diseño común: preservar la jerarquía y la frescura en lugar de aplanar todo en un único bloque de texto.
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
Mantenga la evidencia de la fuente adjunta a cada registro. Una marca de tiempo a nivel de menú no es suficiente cuando los elementos individuales provienen de fuentes diferentes.
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
Un ID de menú debe representar una ubicación y contexto específicos. No combine precios de almuerzo y cena o fusionar menús de entrega y comedor sin reglas explícitas.
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
Utilice solicitudes condicionales, puntos finales de metadatos, ETags o tiempos de modificación de la fuente cuando estén disponibles. Evite descargar repetidamente un menú sin cambios.
La normalización de precios de menú debe preservar la representación original.
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
Nunca convierta "precio de mercado" en cero para visualización posterior. El valor de marcador debe permanecer distinto de un precio numérico real.
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] = []
Un sistema de pedido necesita restricciones de selección, no solo nombres de opciones. "Elija un tamaño" y "elija hasta tres toppings" son reglas diferentes.
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]
No sobrescriba el texto original con una traducción generada. Preservar el idioma original, bandera de traducción, versión del modelo y estado de revisión.
El modelo FoodMenus de Google admite alérgenos y restricciones dietéticas, pero los sistemas posteriores solo deben presentar reclamos respaldados por la fuente.
El guía de alergias alimentarias de la Administración de Alimentos y Medicamentos de EE.UU. ilustra por qué la información sobre alérgenos es relevante para la seguridad.
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
)
No infiera reclamos como "libre de nueces", "libre de gluten", "halal", "kasher" o similares a partir de ingredientes, cocina, imágenes o suposiciones de un modelo de IA.
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
Valide el esquema y conserve la URL de origen, hora observada y hash de contenido con cada registro normalizado.
La recopilación de páginas renderizadas puede ser necesaria cuando un restaurante autorizado no publique ninguna API, feed o datos estructurados. Antes de navegar:
La guía legal de web-scraping de CapSolver proporciona contexto adicional de cumplimiento.
Una página de menú de primera parte puede presentar un desafío compatible durante una ejecución autorizada. CapSolver puede encajar en esta parte estrecha del pipeline después de agotar opciones de API y datos estructurados.
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",
}
)
La página de productos de CapSolver ayuda a confirmar familias de tareas compatibles. Nunca interprete un desafío como datos de menú faltantes.
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
El libro permite a un revisor responder de dónde vino un precio, cuándo fue observado y qué versión del pipeline lo produjo.
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
Estas son políticas internas de ejemplo, no hechos universales. Establezca límites según el comportamiento de la fuente, términos del contrato, expectativas del usuario y riesgo.
class MenuChange(BaseModel):
restaurant_id: str
location_id: str
item_id: str
field: str
before: object
después: objeto
source_before: str
source_after: str
requires_review: bool
HIGH_RISK_FIELDS = {"alérgenos", "etiquetas dietéticas", "disponibilidad", "precio"}
def compare_items(previous: MenuItem, current: MenuItem) -> list[MenuChange]:
changes = []
for field in [
"nombre",
"descripción",
"precio_base",
"opciones",
"etiquetas dietéticas",
"alérgenos",
"disponible",
]:
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
No alertar sobre diferencias en el orden o cambios solo de espacios en blanco. Comparar registros normalizados.
La capa de IA debe responder desde registros verificados y nunca realizar un pedido sin un paso de confirmación separado.
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("precio", proposal.menu_observed_at, now)
and proposal.safety_claims_reviewed
and proposal.quoted_total.amount >= 0
)
Presente la ubicación, el artículo, las opciones, la cantidad, el subtotal, los cargos, los impuestos, el tiempo de observación y los términos de cancelación antes de solicitar la confirmación explícita del usuario.
| Diseño de la pipeline | Calidad de la fuente | Freshness | Seguridad | Recomendación |
|---|---|---|---|---|
| Texto de página aplanado | Baja | Desconocido | Baja | Evitar |
| Agregado de terceros solo | Variable | Variable | Medio | Usar con precaución |
| APIs del propietario más libro de registros normalizado | Alta | Alta | Alta | Preferido |
| Fallback de página de primera parte con revisión | Media | Medible | Medio a alto | Usar cuando esté autorizado |
La arquitectura preferida comienza con fuentes estructuradas controladas por el propietario y mantiene la evidencia adjunta a cada campo.
class QualityResult(BaseModel):
accepted: bool
reasons: list[str]
def validate_item(item: MenuItem) -> QualityResult:
reasons = []
if not item.name.strip():
reasons.append("nombre_faltante")
if item.base_price and item.base_price.amount < 0:
reasons.append("precio_negativo")
if item.evidence.source_type == "REVISIÓN_DE_IMAGEN":
reasons.append("fuente_de_imagen_requiere_revisión")
if item.allergens and item.evidence.source_type not in {
"API_DEL_PROPIETARIO",
"EXPORTACIÓN_DEL_PROPIETARIO",
"PERFIL_DE_NEGOCIO",
}:
reasons.append("fuente_de_alérgeno_requiere_revisión")
return QualityResult(accepted=not reasons, reasons=reasons)
Cuarentene los registros fallidos en lugar de repararlos silenciosamente con texto generado.
Seguir:
No registrar claves de API, cookies, datos de clientes privados, datos de pago o estado completo del almacenamiento del navegador.
La FAQ de AI y automatización de CapSolver puede apoyar el diseño de herramientas y límites de aprobación.
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="contiene maní",
source_type="JSON_LD",
explicit_source_text="contiene maní",
confidence=1.0,
reviewed=False,
reviewer_id=None,
)
assert allow_safety_claim(claim) is False
También probar la precisión de la moneda, la separación de ubicación, las etiquetas multilingües, las restricciones de opciones, los precios obsoletos, los IDs de artículo duplicados, la prioridad de la fuente, la clasificación de páginas de desafío y las puertas de confirmación de pedido.
Código adicional: Use el código WEBS en Dashboard de CapSolver para obtener un 5% adicional en cada recarga.
La FAQ de resolución de CAPTCHA de CapSolver proporciona orientación adicional para pasos de desafío autorizados.
Recopile datos de menú de restaurantes solo de fuentes controladas por el propietario, licenciadas, públicas o explícitamente autorizadas. Respete los acuerdos de API, términos, directivas de robots, límites de tasa, derechos de autor, privacidad y derechos de base de datos. No recopile datos de clientes, pagos, lealtad o pedidos privados. No infiera alérgenos o idoneidad dietética. Un sistema de IA debe presentar el tiempo de fuente y la incertidumbre, y un humano debe confirmar las decisiones importantes.
Los datos de menú de restaurantes para sistemas de pedidos de IA requieren jerarquía, proveniencia, frescura y controles de seguridad. Prefiera APIs del propietario y alimentaciones de POS, preservar el contexto de ubicación y menú, normalizar opciones de artículo y precios, mantener vinculados los variantes de idioma y exigir evidencia explícita para alérgenos y etiquetas dietéticas. Use páginas renderizadas autorizadas solo como un respaldo controlado y trate el manejo de desafíos compatibles como un único paso de infraestructura, no como permiso para acceder a una fuente.
Construya una pipeline autorizada con CapSolver donde los desafíos compatibles interrumpan la recopilación de menú aprobada, luego verifique la fuente, el estado del menú, la proveniencia de los campos y la frescura antes de que un sistema de IA use el resultado.
Use primero una API autorizada por el propietario, alimentación de POS o exportación. Los menús de Perfil de Empresa y datos estructurados de primera parte son fuentes secundarias útiles.
Incluya restaurante, ubicación, menú, sección, artículo, opción, precio, moneda, idioma, etiquetas dietéticas, alérgenos, disponibilidad, fuente y hora de observación.
No debe hacerlo. Presente esos reclamos solo cuando la fuente los indique explícitamente y se cumpla la política de revisión correspondiente.
Establezca políticas específicas por campo. La disponibilidad y los precios generalmente requieren límites más cortos que las descripciones o medios, pero el intervalo exacto depende del comportamiento de la fuente y el riesgo.
CapSolver puede manejar un desafío compatible en una página de primera parte autorizada cuando no haya una fuente estructurada aprobada disponible. El resultado aún necesita validación de página y datos.
Aprende una arquitectura de raspado web escalable en Rust con reqwest, scraper, raspado asíncrono, raspado con navegador sin cabeza, rotación de proxies y manejo de CAPTCHA conforme.

Automatiza la resolución de CAPTCHA con Nanobot y CapSolver. Utiliza Playwright para resolver reCAPTCHA y Cloudflare autónomamente.
