MenuVision

利用 Gemini Vision 和 AI 图像生成,从餐厅网址、PDF 或照片打造美观的 HTML 照片菜单。

已扫描
适合谁
餐厅老板或经理、菜单设计师、需要快速制作数字菜单的餐饮从业者、希望将纸质菜单数字化的用户
不适合谁
不需要数字菜单的用户、无法访问 Google API 的用户
国内可用性
需网络配置。可能需要网络配置或第三方服务可访问。
安装难度
中等(★★☆)。基于终端操作、依赖、API Key 和本地环境要求的初步判断。

安装与下载

openclaw skills install @ademczuk/menuvision

Skill 说明

命令、参数、文件名以原文为准

MenuVision - 餐厅菜单生成器

从 URL、PDF 或照片为任何餐厅构建精美的 HTML 照片菜单。

使用场景

当用户想要为餐厅创建数字菜单时使用。触发词:"构建菜单"、"创建餐厅菜单"、"从 PDF 生成菜单"、"从照片生成菜单"、"数字菜单"、"menuvision"。

快速开始

1. 提取:  URL/PDF/照片  →  menu_data.json     (Gemini Vision)
2. 生成:  menu_data.json →  images/*.jpg        (Gemini Image)
3. 构建:  menu_data.json + images → Menu.html   (CSS/JS 内联,图片相对路径)

示例用法(提供给 AI):

  • "为 https://www.shoyu.at/menus 构建菜单"
  • "从这份 PDF 创建照片菜单" (上传附件)
  • "从这些餐厅菜单照片制作数字菜单"

流程组件

AI 助手会创建以下脚本:

脚本用途
extract_menu.py从 URL/PDF/照片提取菜单数据 → 结构化 JSON
generate_images.py通过 Gemini Image 生成食物照片
build_menu.py从 JSON + 图片构建 HTML 菜单(CSS/JS 内联,图片为相对路径)
publish_menu.py(可选)将 HTML 发布到 GitHub Pages

数据契约(关键)

所有三个流程阶段共享相同的 JSON 模式。AI 助手必须使用这些字段名称——任何偏差都会破坏流程。

{
  "restaurant": {
    "name": "餐厅名称(如可见)",
    "cuisine": "菜系类型(中餐、印度菜、奥地利菜、日本料理等)",
    "tagline": "任何副标题或标语"
  },
  "sections": [
    {
      "title": "分类名称(主要语言)",
      "title_secondary": "分类名称(次要语言,如有;否则为空字符串)",
      "category": "food 或 drink",
      "note": "分类备注(例如“配米饭”、“周一至周五 11-15点”)",
      "items": [
        {
          "code": "M1",
          "name": "菜品名称(主要语言)",
          "name_secondary": "名称(次要语言,如有)",
          "description": "简短描述(主要语言)",
          "description_secondary": "描述(次要语言,如有)",
          "price": "12,90",
          "price_prefix": "",
          "allergens": "A C F",
          "dietary": ["vegan", "spicy"],
          "variants": []
        }
      ]
    }
  ],
  "allergen_legend": {
    "A": "含麸质的谷物",
    "B": "甲壳类动物"
  },
  "metadata": {
    "languages": ["German", "English"],
    "currency": "EUR"
  }
}

字段参考

字段类型必需说明
restaurant.name字符串HTML 标题中显示的餐厅名称
restaurant.cuisine字符串传递给 build_food_prompt() 作为菜系上下文
restaurant.tagline字符串HTML 标题中的副标题行
sections[].title字符串主要语言的分类标题
sections[].title_secondary字符串次要语言的分类标题
sections[].category"food""drink"决定食品网格或饮品列表布局。仅 "food" 类项目会生成图片
sections[].note字符串分类级别备注(例如“配米饭”、“周一至周五 11-15点”)
items[].code字符串每个菜品唯一。与图片文件名关联。使用已有编码(M1, K2)或生成(A1, A2)
items[].name字符串主要语言。对于中日韩菜单,此为中文/日文/韩文名称
items[].name_secondary字符串次要语言。对于中日韩菜单,此为英文/拉丁名称
items[].description字符串简短描述。传递给 build_food_prompt() 用于图片生成
items[].description_secondary字符串次要语言的描述
items[].price字符串保留原始格式("12,90" 而非 "12.90")
items[].price_prefix字符串例如 "ab"(起价)、"ca."(约)
items[].variants数组[{"label": "6块", "price": "8,90"}, ...] — 主价格设为最小变体的价格
items[].allergens字符串按打印格式以空格分隔的编码:"A C F"
items[].dietary数组["vegan", "vegetarian", "spicy", "gluten-free", "halal", "kosher"]
allergen_legend对象过敏原编码到显示名称的映射:{"A": "含麸质的谷物", ...}
metadata.currency字符串ISO 代码:"EUR", "USD", "JPY", "CNY", "THB" 等
metadata.languages数组检测到的菜单语言:["German", "English"]

提取提示词

将以下精确提示词发送给 Gemini。它定义了模式提取规则。不要改写。

技能:MenuVision

版本:1.0.1

分块:2/6

你是一个餐厅菜单数据提取器。请分析以下菜单内容,并将所有项目提取为结构化 JSON。

返回以下精确的 JSON 结构:

{
  "restaurant": {
    "name": "餐厅名称(如果可见)",
    "cuisine": "菜系类型(中餐、印度菜、奥地利菜、日料等)",
    "tagline": "副标题或标语"
  },
  "sections": [
    {
      "title": "分区名称(主语言)",
      "title_secondary": "分区名称(第二语言,若有,否则为空字符串)",
      "category": "food 或 drink",
      "note": "分区的备注(如‘配米饭’、‘周一至周五 11-15点’)",
      "items": [
        {
          "code": "M1",
          "name": "菜品名称(主语言)",
          "name_secondary": "第二语言名称(若有)",
          "description": "简要描述(主语言)",
          "description_secondary": "第二语言描述(若有)",
          "price": "12,90",
          "price_prefix": "",
          "allergens": "A C F",
          "dietary": ["vegan", "spicy"],
          "variants": []
        }
      ]
    }
  ],
  "allergen_legend": {
    "A": "麸质",
    "B": "甲壳类"
  },
  "metadata": {
    "languages": ["德语", "英语"],
    "currency": "EUR"
  }
}

关键规则:

  1. 提取每一个项目。不要跳过任何菜品、饮品或菜单条目。
  2. 保留原始的项目编号/代码(如 M1、K2、S3 等)。如果没有,按分区生成顺序代码(例如开胃菜 A1、A2,主菜 M1、M2)。
  3. 价格原样提取(保留逗号/句点格式)。
  4. 如果项目有价格前缀(如 "ab" – 起价),将其填入 price_prefix
  5. 如果项目有多个尺寸/数量变体(例如 6 个/12 个/18 个,价格不同),使用 variants 数组:

[{"label": "6 个", "price": "8,90"}, {"label": "12 个", "price": "15,90"}]

此时将主 price 设置为最小变体的价格。

  1. 精确捕获过敏原代码(字母、数字或符号)。
  2. 如果可见的过敏原图例,包含在 allergen_legend 中。
  3. 从描述/图标中识别饮食标签:纯素、素食、辣味、无麸质、清真、犹太洁食。
  4. 如果菜单是双语,两种语言都要捕获。将主要语言放入 name/description,第二语言放入 name_secondary/description_secondary。
  5. 对于套餐或午餐特价(固定价格包含多种选择),创建一个分区,用 note 解释格式,并将每个选项列为一个项目。
  6. 每个分区分类为 "food" 或 "drink"。
  7. 对于饮品,同样提取名称、价格以及任何尺寸变体。

只返回有效的 JSON。不要使用 markdown 代码块,不要附加解释文字。


视觉提示变体

对于基于图像输入(截图、PDF 页面、照片),在基本提示前添加一行上下文:

EXTRACTION_PROMPT_VISION = (
    "You are a restaurant menu data extractor. "
    "This is a photo/scan of a restaurant menu page.\n\n"
    "Return this exact JSON structure:"
    + EXTRACTION_PROMPT.split("Return this exact JSON structure:")[1]
)

然后每种输入类型添加各自的前缀:

输入类型EXTRACTION_PROMPT_VISION 前添加的前缀
截图"This is a screenshot of a restaurant menu webpage at {url}. Extract ALL visible menu items.\n\n"
PDF 页面"This is page {n} of a restaurant menu PDF. Extract ALL menu items from this page.\n\n"
照片"This is a photograph of a restaurant menu. Extract ALL visible menu items.\n\n"
文本(静态 HTML)直接使用 EXTRACTION_PROMPT(无需视觉变体)

GEMINI API 配置

import os
from google import genai

client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])

def gemini_config():
    return genai.types.GenerateContentConfig(
        max_output_tokens=65536,          # 64K — 大菜单需要
        response_mime_type="application/json",  # JSON 模式 — 关键
    )

# 模型: gemini-2.5-flash (默认)
response = client.models.generate_content(
    model="gemini-2.5-flash",
    contents=prompt_text,    # 或 [image, prompt_text] (视觉输入)
    config=gemini_config(),
)

# 始终检查截断
if response.candidates[0].finish_reason.name == "MAX_TOKENS":
    print("WARNING: Response truncated. Menu may be incomplete.")

图像提示模板

使用以下精确函数。它模拟随意手机拍照的美感,使菜单看起来更真实。

def build_food_prompt(name: str, description: str, cuisine: str = "") -> str:
    cuisine_context = f" {cuisine}" if cuisine else ""
    food_desc = f"{name}"
    if description and description != name:
        food_desc += f" ({description})"

    return (
        f"A photo of {food_desc} at a{cuisine_context} restaurant. "
        f"Taken casually with a phone from across the table at a 45-degree angle. "
        f"The plate sits on a dark wooden table and takes up only 30% of the frame. "
        f"Lots of visible table surface around the plate. Chopsticks, napkins, "
        f"a glass of water, and small side dishes scattered naturally nearby. "
        f"Blurred restaurant interior in the background — other diners, pendant lights, "
        f"wooden chairs visible but out of focus. Warm ambient lighting. "
        f"NOT a close-up. NOT professional food photography. "
        f"It looks like someone quickly snapped a photo before eating."
    )

图像生成 API 调用

Gemini 2.5 Flash 图像

import os, io
from PIL import Image
from google import genai

client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])

def generate_gemini(client, name, description, output_path, cuisine=""):
    prompt = build_food_prompt(name, description, cuisine)

    response = client.models.generate_content(
        model="gemini-2.5-flash-image",       # 注意:不是 gemini-2.5-flash(该模型仅支持文本)
        contents=prompt,
        config=genai.types.GenerateContentConfig(
            response_modalities=["TEXT", "IMAGE"],  # 关键——请求图像输出
        ),
    )

    # 从响应部分中提取生成的图像
    for part in response.candidates[0].content.parts:
        if part.inline_data is not None:
            img = Image.open(io.BytesIO(part.inline_data.data)).convert("RGB")
            # 中心裁剪为正方形,调整为 800x800
            w, h = img.size
            side = min(w, h)
            left = (w - side) // 2
            top = (h - side) // 2
            img = img.crop((left, top, left + side, top + side))
            img = img.resize((800, 800), Image.LANCZOS)
            img.save(str(output_path), "JPEG", quality=82)
            return
    raise RuntimeError("Gemini 响应中未找到图像")

跳过饮品

仅对 category == "food" 的章节生成图像。饮品在 HTML 输出中仅以纯文本列表形式显示。


多语言 / CJK 处理

菜单可以是任何语言。流水线通过双语字段和智能提示路由进行处理。

提取(所有语言)

  • name / description = 主要语言(菜单主要使用的语言)
  • name_secondary / description_secondary = 次要语言(若为双语)
  • 适用于:德语/英语、中文/英语、日语/英语、泰语/英语、阿拉伯语/英语、韩语/英语等

图像生成(CJK 安全提示)

CJK 字符会导致图像提示不佳。在调用 build_food_prompt() 之前,请切换为拉丁名称:

def prepare_for_image_gen(name, name_secondary, description):
    """为图像提示使用拉丁字母名称。CJK → 使用次要名称。"""
    display_name = name
    if name_secondary:
        if any(ord(c) > 0x2E80 for c in name):  # CJK/韩文/假名检测
            display_name = name_secondary
            description = description or name
        else:
            description = description or name_secondary
    return display_name, description

**ord(c) > 0x2E80 覆盖的 Unicode 范围:**

  • CJK 统一表意文字(汉字)
  • 平假名 / 片假名(日语)
  • 韩文(韩语)
  • CJK 兼容表意文字、部首补充、扩展区

HTML 输出(所有文字系统)

  • name 呈现为大号显示文本
  • name_secondary 在其下方以较小字体显示
  • 两者均使用支持 CJK 回退的 Google 字体(Noto Sans SCNoto Sans JPNoto Sans KR

文件命名约定

自动推导

所有文件名均从餐厅名称或来源 URL 推导:

stem = "shoyu"  # 从 URL 域名、PDF 文件名或餐厅名称推导
data_file = f"menu_data_{stem}.json"
images_dir = Path(f"images/{stem}")
html_file = f"{restaurant_name}_Menu.html"  # 例如:"Shoyu_Menu.html"

图像文件

images/{restaurant_stem}/{code}.jpg

# restaurant_stem = 数据文件名去掉 "menu_data_" 前缀
# 示例:menu_data_shoyu.json → images/shoyu/M1.jpg

图像路径匹配(在构建步骤中)

返回 POSIX 风格的路径字符串,并带有 ./ 前缀,以实现跨平台 HTML 兼容性:

def find_image(code: str, images_dir: Path):
    """返回指向图像的相对 POSIX 路径字符串,若无则返回 None。"""
    if not images_dir.is_dir():
        return None
    rel = images_dir.as_posix()
    if not rel.startswith("./"):
        rel = "./" + rel
    # 1. 精确匹配
    for ext in ("jpg", "jpeg", "webp", "png"):
        candidate = images_dir / f"{code}.{ext}"
        if candidate.exists():
            return f"{rel}/{code}.{ext}"
    # 2. 不区分大小写的回退
    for f in images_dir.iterdir():
        if f.stem.lower() == code.lower() and f.suffix.lower() in (".jpg", ".jpeg", ".webp", ".png"):
            return f"{rel}/{f.name}"
    return None

输出 HTML

{RestaurantName}_Menu.html    # CSS/JS 内联,图像为相对文件路径

图像渲染(构建步骤)

构建脚本使用 find_image() 解析每个食品项的照片,若无图像则回退为渐变色 SVG 占位符。

python

import base64

import html as html_mod

GRADIENT_COLORS = [

("#c41e3a", "#8b0000"), ("#ff6b6b", "#ee5a24"), ("#fdcb6e", "#e17055"),

("#00b894", "#00cec9"), ("#6c5ce7", "#a29bfe"), ("#e17055", "#d63031"),

("#00cec9", "#0984e3"), ("#fab1a0", "#e17055"), ("#e8a87c", "#d4956b"),

("#fd79a8", "#e84393"),

]

def make_placeholder_svg(code: str, name: str, secondary: str = "") -> str:

"""Generate a base64-encoded SVG placeholder when no image exists."""

idx = hash(code) % len(GRADIENT_COLORS)

c1, c2 = GRADIENT_COLORS[idx]

display = html_mod.escape(secondary[:12] if secondary else name[:12])

svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="220" height="180" viewBox="0 0 220 180">

<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">

<stop offset="0%" style="stop-color:{c1}"/>

<stop offset="100%" style="stop-color:{c2}"/>

</linearGradient></defs>

<rect width="220" height="180" fill="url(#g)" rx="12"/>

<text x="110" y="75" text-anchor="middle" fill="rgba(255,255,255,0.25)" font-size="56" font-family="serif">{html_mod.escape(code)}</text>

<text x="110" y="120" text-anchor="middle" fill="white" font-size="26" font-family="serif" opacity="0.9">{display}</text>

<text x="110" y="148" text-anchor="middle" fill="rgba(255,255,255,0.6)" font-size="11" font-family="sans-serif">{html_mod.escape(name[:30])}</text>

</svg>'''

b64 = base64.b64encode(svg.encode("utf-8")).decode("ascii")

return f"data:image/svg+xml;base64,{b64}"

def image_tag(code: str, name: str, secondary: str, images_dir: Path, portable: bool = False) -> str:

"""Return <img> tag — real image OR gradient SVG placeholder.

If portable=True, embed the real image as base64 data URI for single-file output."""

real = find_image(code, images_dir)

if real:

if portable:

img_path = images_dir.parent / real # resolve relative path

with open(img_path, "rb") as f:

b64 = base64.b64encode(f.read()).decode("ascii")

return f'<img src="data:image/jpeg;base64,{b64}" alt="{html_mod.escape(name)}">'

return f'<img src="{html_mod.escape(real)}" alt="{html_mod.escape(name)}" loading="lazy">'

else:

src = make_placeholder_svg(code, name, secondary)

return f'<img src="{src}" alt="{html_mod.escape(name)}">'

### 输出模式

HTML 生成器支持两种输出模式,由 `--portable` 标志控制:

| 模式 | 标志 | 图片 | 输出 | 使用场景 |
|------|------|--------|--------|----------|
| **便携式**(默认) | `--portable` 或未设置 `GITHUB_*` 环境变量 | Base64 嵌入 HTML | 单个自包含 `.html` 文件 | 本地打开、邮件发送、拖放到任意主机 |
| **部署式** | `--no-portable` 或已设置 `GITHUB_*` 环境变量 | 相对路径(`./images/stem/code.jpg`) | HTML + `images/` 目录 | GitHub Pages、Netlify 等静态托管 |

**便携模式**将所有食物图片以 base64 数据 URI 的形式直接嵌入 HTML。文件体积较大(80 项菜单约 4~6 MB),但输出为单文件,无需任何托管设置即可在所有环境使用。未设置 `GITHUB_*` 环境变量时默认使用此模式。

**部署模式**使用相对图片路径,需要将 HTML 文件和 `images/` 目录一起托管。当发布到 GitHub Pages 或任何静态托管服务时使用此模式。

---

## 鲁棒性模式

### 重试逻辑
所有 Gemini API 调用应在临时故障时重试:

import time

def call_with_retry(fn, *args, max_retries=3, **kwargs):

"""Retry API calls with exponential backoff."""

for attempt in range(max_retries):

try:

return fn(*args, **kwargs)

except Exception as e:

if attempt == max_retries - 1:

raise

wait = 2 ** attempt

print(f" Retry {attempt + 1}/{max_retries} in {wait}s: {e}")

time.sleep(wait)

### JSON 响应解析
Gemini 有时会将 JSON 包裹在 Markdown 围栏中或产生尾随逗号。防御性解析——先尝试原始解析,仅作为最后手段应用尾随逗号修复(无条件的修复可能会破坏包含 `,]` 模式的有效 JSON 字符串):

import re, json

def parse_gemini_json(raw: str) -> dict:

"""Parse JSON from Gemini, handling markdown fences and quirks."""

text = raw.strip()

# Strip markdown code fences

if text.startswith("```"):

text = re.sub(r"^```\w*\n?", "", text)

text = re.sub(r"\n?```$", "", text)

text = text.strip()

# Try direct parse first

try:

return json.loads(text)

except json.JSONDecodeError:

pass

# Try extracting JSON object from surrounding text

match = re.search(r"\{.*\}", text, re.DOTALL)

if match:

candidate = match.group(0)

try:

return json.loads(candidate)

except json.JSONDecodeError:

pass

# Fix trailing commas and retry

candidate = re.sub(r",\s*([\]}])", r"\1", candidate)

try:

return json.loads(candidate)

except json.JSONDecodeError:

pass

# Last resort: fix trailing commas on original

text = re.sub(r",\s*([\]}])", r"\1", text)

return json.loads(text)

### 后处理
提取后,执行这些清理工作:

def generate_codes(data: dict) -> dict:

"""Ensure every item has a unique code. Generates sequential codes per section

if items have empty/missing codes (e.g. A1, A2 for appetizers, M1, M2 for mains)."""

# ... assign prefix by section title, increment counter per section

return data

def normalize_prices(data: dict) -> dict:

"""Normalize price formats: numeric → string, strip currency symbols,

preserve comma/period format as-is."""

# ... convert float/int to string, strip €/$, etc.

return data

### 货币映射
将 ISO 货币代码映射到 HTML 输出的显示符号:

CURRENCY_MAP = {

"EUR": "€", "USD": "$", "GBP": "£", "CHF": "CHF ",

"JPY": "¥", "CNY": "¥", "INR": "₹", "AUD": "A$",

"CAD": "C$", "SEK": "kr ", "NOK": "kr ", "DKK": "kr ",

"THB": "฿", "KRW": "₩", "HKD": "HK$", "SGD": "S$",

"CZK": "Kč ", "HUF": "Ft ", "PLN": "zł ", "TRY": "₺",

}

提取细节

HTML 链接

  1. 使用 requests 获取页面
  2. 检查文本密度以判断是静态页面还是 JS 渲染:

density = len(soup.get_text(strip=True)) / len(raw_html)

  1. 密度覆盖:如果发现 5 个以上价格模式 (r"[$€£¥₹CHF]\s*\d+[.,]\d{2}|\d+[.,]\d{2}\s*[$€£¥₹]"),强制密度为 1.0(视为静态)
  2. 静态(密度 >= 0.02):清理 HTML,将文本发送至 Gemini 2.5 Flash(JSON 模式)
  3. JS 渲染(密度 < 0.02,例如 Wix、Framer):使用 Playwright 截图,发送至 Gemini Vision
  4. 截图高度限制:如果截图高度 > 6000px,按比例缩放以适应
  5. 大型菜单(文本超过 12k 字符):分块提取,合并方式类似 PDF 多页。跨块去重:跟踪 seen_codes = set(),对于每个块中每个项目的 item["code"],如果已在 seen_codes 中则跳过。仅保留去重后仍有项目的 sections。

PDF 文件

  1. 使用 PyMuPDF 将每页转换为图像(200 DPI)
  2. 将每页图像发送至 Gemini Vision
  3. 跨页合并结果(按代码去重)

照片

  1. 直接加载图像
  2. 如果大于 10MB,调整大小
  3. 发送至 Gemini Vision

HTML 输出特性

  • 三列 Instagram 风格网格(9:16 竖版卡片)
  • 渐变文字叠加显示:名称 + 第二语言 + 价格
  • 点击选中并显示绿色勾选标记
  • 在“选择”选项卡中显示账单,带有 +/- 数量控制
  • 分类药丸导航,支持滚动同步
  • 网格下方饮品区域,价格带货币前缀
  • 过敏原图例
  • 货币转换器 — 头部极简按钮(例如 药丸),可循环或打开选择器:EUR、USD、AUD、CAD、GBP。使用构建时嵌入的快照汇率,在客户端转换所有显示价格。更新网格叠加、账单总计、饮品价格和变体价格。源货币来自 metadata.currency
  • 完全响应式,暗黑模式
  • 所有 CSS/JS 内联,图像使用相对文件路径(./images/{stem}/{code}.jpg),仅 Google Fonts 为外部资源
  • 缺失图像时使用渐变色 SVG 占位(内联 base64 SVG,非栅格化)
  • CJK 字体加载:通过 Google Fonts 链接标签:

family=Noto+Sans+SC:wght@400;700&family=Noto+Sans+JP:wght@400;700&family=Noto+Sans+KR:wght@400;700

  • CSS font-family 堆栈:主要字体,然后是 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', sans-serif

货币转换器

内嵌在 HTML 输出中的极简货币切换,全部在客户端完成,运行时无需 API 调用。

实现:

  • 构建脚本嵌入 RATES 对象,包含构建时的快照汇率(基准:USD)
  • 源货币从 JSON 数据中的 metadata.currency 读取
  • 所有价格以 数字 值存储在 data-price 属性中(而非原始字符串如 "12,90")
  • 头部一个小药丸按钮显示当前货币符号(例如
  • 点击打开迷你选择器或循环切换:EUR ()、USD ($)、GBP (£)、AUD (A$)、CAD (C$)
  • 货币变化时,JavaScript 转换所有 data-price 值并更新显示文本
  • 选择选项卡中的账单总计也通过 convertPrice() 使用 SOURCE_CURRENCYcurrentCurrency 转换
  • 变体价格也会更新
  • 所选货币持久化到 localStorage

价格解析辅助函数(构建时 — 将字符串价格转换为数字,用于 data-price 属性):

import re

def _parse_price_numeric(price: str) -> str:
    """将价格字符串解析为数字浮点数,用于 data-price 属性。"""
    matches = re.findall(r"(\d+[.,]\d+)", price)
    if matches:
        return str(float(matches[-1].replace(",", ".")))
    return "0"

# 在 HTML 模板中用法:
# <div class="price" data-price="{_parse_price_numeric(item['price'])}">€12,90</div>
// 构建时嵌入的快照汇率(基准:USD)
const RATES = { EUR: 0.92, USD: 1.00, GBP: 0.79, AUD: 1.54, CAD: 1.36 };
const SYMBOLS = { EUR: "€", USD: "$", GBP: "£", AUD: "A$", CAD: "C$" };
const SOURCE_CURRENCY = "EUR";  // 来自 metadata.currency

function convertPrice(amount, fromCurrency, toCurrency) {
    const inUSD = amount / RATES[fromCurrency];
    return inUSD * RATES[toCurrency];
}

// 应用于:网格覆盖价格、饮品列表价格、变体价格
// 以及收据/选择选项卡总计(所有带 data-price 属性的元素)

构建脚本应在构建时获取当前汇率(离线时使用合理的默认值)。价格以目标货币显示两位小数,并使用目标地区的格式。

品牌定制

--name "Restaurant Name"     # 头部品牌文本
--tagline "Cuisine · City"   # 副标题
--accent "#ff6b00"           # 主色(药丸、激活选项卡、饮品价格)
--bg "#0a0a0a"               # 背景颜色

成本摘要

组件成本
提取(每页)~$0.001
图像生成(每道菜品)$0.039
80 道菜品~$3.12
时间(80 道菜品)~8 分钟

饮品不生成图像(纯文本列表),因此实际成本取决于菜品与饮品的比例。

依赖项

需要 Python 3.9+

必需:

  • google-genai(提取 + 图像生成)
  • Pillow(图像处理)

对于 HTML 链接:

  • requests(HTTP 获取)
  • beautifulsoup4(HTML 解析)

对于 JS 渲染网站:

  • playwright(无头浏览器截图)

对于 PDF 文件:

  • PyMuPDF(PDF 转图像)
pip install google-genai Pillow requests beautifulsoup4 PyMuPDF
pip install playwright && playwright install chromium

环境变量

  • GOOGLE_API_KEY — 提取和图像生成必需
  • GITHUB_PAT — GitHub Pages 发布必需
  • GITHUB_OWNER — 你的 GitHub 用户名(默认:从 git 配置读取)
  • GITHUB_REPO — 你的 GitHub Pages 仓库名称(默认:menus

发布

默认:便携式 HTML(无需设置)

当未设置任何 GITHUB_* 环境变量时,流水线会生成一个自包含的 HTML 文件,其中图片以 base64 内嵌。用户可以:

  • 直接在任意浏览器中打开该文件
  • 通过邮件或任意文件分享服务发送
  • 上传到任意静态托管平台(Netlify Drop、Vercel、GitHub Pages、S3)

无需设置托管环境,除了 GOOGLE_API_KEY 外无需任何 API 密钥,也无需配置 git。

可选:GitHub Pages(需要设置)

对于希望拥有带多个菜单的永久展示页的用户:

  1. 为你的菜单创建一个 GitHub 仓库(例如 your-username/menus
  2. main 分支上启用 GitHub Pages
  3. 设置环境变量(必须能被 Python 进程访问到):
export GITHUB_PAT="your-personal-access-token"   # 必填——用于 git push 认证
export GITHUB_OWNER="your-username"               # 必填——你的 GitHub 用户名
export GITHUB_REPO="menus"                        # 可选——默认为 "menus"

重要: publish_menu.py 必须从环境变量中读取 GITHUB_OWNERGITHUB_REPO。切勿将特定用户的仓库硬编码。生成的代码应动态构建仓库 URL:

owner = os.environ["GITHUB_OWNER"]
repo = os.environ.get("GITHUB_REPO", "menus")
GITHUB_REPO = f"{owner}/{repo}"
GITHUB_PAGES_BASE = f"https://{owner}.github.io/{repo}"

发布

python publish_menu.py Restaurant_Menu.html --name "Restaurant" --tagline "Cuisine · City" --cuisine Type

展示页地址:https://<your-username>.github.io/<repo>/

发布的工作原理

publish_menu.py 将菜单仓库克隆到本地文件系统的一个临时目录中(git clone --depth=1),将文件复制到那里,提交并推送。这避免了在 Docker 容器中直接操作已挂载卷时出现的所有 NTFS 绑定挂载权限问题。

关键实现细节:

  1. git clone --depth=1tempfile.mkdtemp() 目录(本地文件系统,拥有正确的 POSIX 权限)
  2. 使用 shutil.copy()(而非 copy2)复制 HTML 和图片——避免在 NTFS 上因 os.chmod() 出现 EPERM 错误
  3. find_image_dirs 的正则表达式使用 [^/"]+(而非 [a-z_]+)以匹配图片目录名中的 Unicode 字符
  4. 为展示元数据写入 .meta_ JSON 附属文件
  5. 重建展示页 index.html
  6. 通过克隆 URL 中嵌入的 GITHUB_PAT 环境变量进行身份验证推送
  7. 推送后清理临时目录
  8. MENUS_REPO_DIR(绑定挂载路径)仅用于 --list 只读查询

外部端点

端点发送的数据用途
generativelanguage.googleapis.com菜单文本、页面截图、PDF 页面图片、食物照片提示词Gemini API 用于提取(JSON 模式)和图片生成
目标餐厅 URL仅 HTTP GET获取菜单页面 HTML 用于提取
api.github.com生成的 HTML 文件、图片文件将菜单发布到 GitHub Pages(可选,需要 GITHUB_PAT
fonts.googleapis.com无(HTML 输出中的 CSS 链接)Google Fonts 在浏览器中打开菜单 HTML 时由客户端加载

没有分析、遥测或跟踪功能。除上述端点外,不会向任何端点发送数据。

安全与隐私

  • API 密钥GOOGLE_API_KEY 从环境变量中读取,绝不会硬编码或记录到日志中
  • GitHub PAT:仅用于向用户自己的仓库进行身份验证推送;不会传输到其他地方
  • 餐厅数据:菜单内容会发送到 Gemini API 进行处理。除 Google 的标准 API 保留政策外,不会在服务器端存储数据
  • 生成的图片:本地存储在 images/ 目录中。发布时,仅上传到用户自己的 GitHub Pages 仓库
  • 无遥测:流水线不会收集任何分析、指标或使用数据
  • 本地优先:除 Gemini API 调用外,所有处理均在本地完成。HTML 输出和图片保留在用户机器上,除非用户明确发布

已知限制

  • 带有标签页的 Wix 菜单:仅提取第一个可见标签页
  • Google 地图图片 URL:不支持(请使用直接图片文件)
  • 非常大的菜单(超过 300 项):可能需要手动分块审查
A
@ademczuk

已收录 1 个 Skill

相关推荐