内容
<div align="center">
# 🍔 订单卡路里一览 · mcd-order-calories
### 把麦当劳历史订单,折算成看得懂的卡路里
**说一句「麦麦,今天吃了多少卡」或「算算我吃过多少卡」,它拉出你的历史订单、展开套餐成分、对齐营养表,逐单给出热量——并且如实告诉你哪几项没算出来。**





> 💛 如果这个思路对你有用,点个 **⭐ Star** 支持一下!本作品正在参加「麦当劳程序员节创意开发大赛」,Star 数决定排名 🙏
</div>
---
## 📖 项目介绍
**订单卡路里一览**是一个基于**麦当劳中国 MCP** 的**只读** Skill:拉取你的历史订单,展开套餐成分,与官方营养表对齐,逐单算出热量。
算法本身只有一行 `Σ(kcal × 数量)`。真正的工作量在于**商品归一**——订单与营养表只能按**商品名**对齐,而两边命名习惯不同(套餐容器 vs 真实成分、`可口可乐中杯` vs `可乐中杯`、全角括号 vs 半角…)。本项目用**五级级联匹配**处理,并且**算不出来的部分如实披露,绝不拿一个偏低的下界冒充总量**。
| | |
| ------ | ------------------------------------------------------- |
| **输入** | 你的麦当劳账号(通过 `MCD_MCP_TOKEN`) |
| **输出** | 每单热量 + 覆盖率 + 每个数的来源层级与不确定性 |
| **调用** | 仅 `order-list` 与 `list-nutrition-foods` 两个查询接口,**全程只读** |
| **依赖** | 零第三方依赖,纯 Python 标准库;`clone` 下来零安装即可运行与测试 |
---
## 😩 你有没有过这种经历
连着几周中午都吃麦当劳,某天突然想知道:**我这一个月到底吃进去多少卡?**
- 麦当劳 App 只显示**订单**,不显示**热量**;
- 营养表只有**单品**数据,你得自己一个个对;
- 更麻烦的是——订单里写「**人气经典随心配**」,营养表里写「**双层吉士汉堡**」+「**小杯玉米杯**」。**套餐是个容器,真正吃进去的是里面的成分**;
- 好不容易对上几个,发现还有「可口可乐中杯」在营养表里叫「**可乐中杯**」……
**这个 Skill 就是把这件事做完,而且做不完的部分如实告诉你。**
---
## 🎬 真实运行输出
```console
$ mcd-calories
订单卡路里一览
营养表 158 条 · 别名表 2 条 · 已判定缺口 0 条
⚠️ 数据源:营养表存在重复条目:小杯玉米杯、纯牛奶(盒装)
==============================================================================
2026-09-18 12:29 482 kcal 2/2 ***得来速餐厅
2026-09-07 18:10 2225 kcal 8/8 麦当劳***餐厅
2026-09-03 10:14 526 kcal 2/2 麦当劳***餐厅
==============================================================================
完整覆盖 10/10 单 · 覆盖商品 45/45 项 · 已覆盖部分合计 12290 kcal
```
加 `--detail` 看每一单吃的是什么、每个数是怎么来的:
```console
$ mcd-calories --detail
2026-09-07 18:10:14 · 麦当劳上海古浪路桃浦星品荟餐厅 · 实付 ¥62.9
🔥 2225 kcal
· 泰式炭烤风味猪猪堡 450 kcal [supplement·估算 450(430~495),medium 置信度]
· 风味双层深海鳕鱼堡 485 kcal [guarded]
· 麦乐鸡4块 170 kcal [exact]
· 麦辣鸡翅2块 224 kcal [exact]
· 中薯条 289 kcal [exact]
· 草莓麦旋风 295 kcal [exact]
· 【美汁源】“多汁柠柠” 165 kcal [category:minute_maid·估算]
· 可口可乐中杯 147 kcal [guarded]
ℹ️ 「泰式炭烤风味猪猪堡」为推导估算值 450(430~495) kcal(medium 置信度,非营养表数据)
推导:【锚点自检】双层猪柳蛋麦满分 513 = 马芬137 + 猪柳饼126×2 + 蛋79 + 芝士46 = 514 ✓
【方法A】面包113 + 猪柳饼126×2 + 酱85 = 450
【方法B】513 − 24(马芬→面包) − 79(去蛋) − 46(去芝士) + 85(加酱) = 449
来源:...①本堡是否含芝士——若含,实际值约 496(故区间上界给到 495)...
```
---
## 🚀 快速开始
三步:**安装 → 配置 Token → 运行**。需要 Python ≥ 3.9,**零第三方依赖**。
### 安装方法
有两种,任选:
```bash
# 方式一:安装为命令行工具(推荐)
git clone <this-repo>
cd mcd-order-calories
python3 -m pip install -e .
mcd-calories # 安装后可直接用
# 方式二:不安装,直接以模块方式运行
PYTHONPATH=src python3 -m mcd_order_calories.cli
```
### 配置 Token
在 [M-China/mcd-mcp-server](https://github.com/M-China/mcd-mcp-server) 申请后设置环境变量:
```bash
export MCD_MCP_TOKEN="你的 Token"
```
### 使用示例
```bash
mcd-calories # 订单卡路里一览(默认命令,不写子命令也行)
mcd-calories --detail # 展开每单的商品明细与估算推导
mcd-calories --json # 结构化输出,便于程序消费
mcd-calories gaps # 列出未匹配商品,区分「已知缺口」与「新出现」
mcd-calories explain 双层脆鸡堡 # 解析单个商品名,展示判定理由与全部候选
mcd-calories doctor # 检查连通性与所需工具
```
未安装时把上面的 `mcd-calories` 换成 `PYTHONPATH=src python3 -m mcd_order_calories.cli`。
---
## 🎯 真正难的不是算术,是「商品归一」
算法只有一行:`Σ(kcal × 数量)`。**难点在于订单和营养表只能按商品名对齐,而两边命名习惯不同。**
| 订单里写的 | 营养表里写的 | 差异类型 |
| ----------- | -------------- | ------------ |
| 人气经典随心配 | 双层吉士汉堡 + 小杯玉米杯 | **套餐 vs 成分** |
| 可口可乐中杯 | 可乐中杯 | 少一个前缀 |
| 纯牛奶(盒装) | 纯牛奶(盒装) | 半角 / 全角 |
| 麦辣鸡翅2块 | 麦辣鸡翅-2块 | 连字符 |
| 那么大鸡排(椒盐风味) | 那么大鸡排 | 尾部口味后缀 |
| 【美汁源】“多汁柠柠” | *(营养表没有)* | **覆盖缺口** |
本项目用**五级级联**处理,每一级都有明确的采纳条件:
| 层级 | 名称 | 采纳条件 | 信任度 |
| -- | ------------------------- | --------------------- | --- |
| 1 | `alias` | 人工复核过的别名表 | 最高 |
| 2 | `exact` | 归一化后完全相等 | 高 |
| 3 | `supplement` | 人工推导的估算值(附推导+区间+来源) | 估算 |
| 4 | `category` | 命中品类规则(蘸酱、【美汁源】、厚松饼堡) | 估算 |
| 5 | `stripped` | 剥掉尾部括号后与营养表**完全相等** | 高 |
| 6 | `guarded` | 候选经硬约束过滤后**只剩一个** | 中 |
| 7 | `ambiguous` / `unmatched` | 多候选或没候选 → **不猜,如实上报** | — |
用 `mcd-calories explain <商品名>` 可以看到任意一个商品名的判定理由与**全部候选**(含被淘汰的原因):
```console
$ mcd-calories explain 那么大鸡排(椒盐风味)
判定:stripped
结果:那么大鸡排 385.0 kcal
理由:剥掉尾部括号说明「(椒盐风味)」后与营养表条目一致
$ mcd-calories explain 双层脆鸡堡
判定:supplement
结果:双层脆鸡堡 520.0 kcal
理由:命中补充热量表(本项目推导),520(490~560) kcal(medium 置信度,非营养表数据)
```
---
## 🔒 四条不可动摇的规则
### 1. 硬约束优先于相似度——因为错答案的相似度更高
实测数据:
```
similarity(可口可乐中杯, 无糖可口可乐中杯) = 0.732 ← 错,且更高
similarity(可口可乐中杯, 可乐中杯) = 0.633
```
含糖与无糖只差两字,字符串上反而更「像」。如果按相似度排序取第一,**这一项会算错约 150 kcal**。
所以本项目先做**硬约束**检查(含糖标记 / 杯型 / 规格 / 数量 / 冷热),冲突的候选一律淘汰,**且单方未标注不算冲突**(否则「可乐中杯」永远匹配不上「可口可乐中杯」)。
实际输出——注意 `✗` 那一行的相似度**比正确答案还高**:
```console
$ mcd-calories explain 可口可乐中杯
查询:可口可乐中杯
判定:guarded
结果:可乐中杯 147.0 kcal
理由:唯一候选,相似度 0.633;另有 2 个候选被硬约束淘汰
候选(相似度仅供人工参考,不作为自动决策依据):
✓ 可乐中杯 0.633
✗ 无糖可口可乐中杯 0.732 ← 含糖标记不一致:无糖 ≠ 含糖
```
**这就是为什么本项目从不按相似度排序取第一。**
### 2. 唯一性才自动采纳
过滤后多于一个候选,一律判 `ambiguous` 交人工,**不排序取第一**。
自动采纳门槛 0.60 是**用真实数据标定**的,不是拍的:门槛 0.30 时实测出两个假阳性——
```
【美汁源】“多汁柠柠” → 【美汁源】“黄金橙橙”(相似度 0.314) 不同口味
双层脆鸡堡 → 双层吉士汉堡 (相似度 0.312) 不同商品
```
安全区间实测为 `(0.312, 0.633]`。**宁可漏,不可错**——热量工具里的假阳性会静默污染数值,比承认一个缺口糟糕得多。
### 3. 未支付订单不计入「已摄入」
`order-list` 会返回**未支付**订单。实测账号里就有 1 条「待支付」——如果直接计入合计,
就会把**还没吃到的东西算成已经吃进去的热量**。
所以本项目把订单状态分类(规则见 `data/order_status.json`):**只有「订单已完成」计入合计**,
待支付 / 进行中 / 已取消 / 状态未识别一律**单独展示并披露**:
```console
【已消费】计入合计
2026-09-18 12:29 482 kcal 2/2 上海康建商务广场得来速餐厅
...
==============================================================================
【待支付】**不计入合计** —— 未支付,还没吃到
2026-10-09 17:01 ≥436 kcal 2/4 ⚠️ 麦当劳上海古浪路桃浦星品荟餐厅
==============================================================================
已消费 9 单 · 完整覆盖 9/9 单 · 覆盖商品 41/41 项 · 已覆盖部分合计 11211 kcal
未计入合计:待支付 1 单(≥436 kcal)
```
待支付订单的卡路里**仍然会算出来**——它本身是有用信息("你有个待支付订单,约 436 kcal"),
只是不能混进「已摄入」。
> ⚠️ 另外注意:`order-list` **没有分页参数,只返回最近 10 条**。新订单会把最老一条挤出窗口,
> 所以本项目给出的是**近期窗口**内的统计,不是全部历史。
### 4. 覆盖率是硬性输出,绝不隐藏
实测某单只覆盖 1/4 项时算出 87 kcal,而真实热量在 800 kcal 以上。**只给数字不给出覆盖率会严重误导。**
所以输出永远同时给数值和覆盖率,覆盖率不足 100% 一律标注为**下界**:
```
🔥 ≥234 kcal ⚠️ 仅覆盖 2/4 项(50%)
⚠️ 缺少:枫糖风味厚松饼猪柳堡、世界波脆鸡排蛋堡
⚠️ 实际热量显著高于此值,请勿据此做热量控制
```
---
## 🧩 估算值也是可审计的
营养表只收录 158 条,而实际商品远多于此。本项目用**四张人工维护表**分层处理,每层职责明确:
| 文件 | 用途 | 要求 |
| ------------------ | -------------- | ----------------------------------- |
| `aliases.json` | 订单名 → 营养表已有条目 | 需说明判断依据 |
| `categories.json` | 整个品类共用默认值 | 需说明来源 |
| `supplements.json` | 单个商品的**推导估算值** | **必须附推导过程 + 误差区间 + 非官方来源**(缺一即加载报错) |
| `known_gaps.json` | 确认算不出,如实披露 | 需说明为什么 |
`supplements.json` 里的每个值都附带完整推导,例如**双层脆鸡堡**:
```
【基准值校准】芝士 = 吉士汉堡包294 − 汉堡包248 = 46
牛肉饼 = 双层吉士汉堡429 − 吉士汉堡包294 = 135
面包 = 汉堡包248 − 135 = 113 (自检 294 + 135 = 429 ✓)
【反推鸡排】 麦香鸡369 = 面包113 + 鸡排 + 生菜4 + 酱100 → 鸡排 ≈ 152
【方法A】 113 + 2×152 + 100 = 517
【方法B】 双层吉士汉堡/吉士汉堡包 = 1.459;369 × 1.459 = 538
【结论】 两法收敛于 517~538,取中值 520(区间 490~560)
```
并且**所有补充值共用同一套基准值**(面包113 / 马芬137 / 蛋79 / 芝士46 / 猪柳饼126 / 脆鸡扒178 / 麦香鸡鸡排152 / 酱85~100),有三组独立自检闭合:
```
火腿扒麦满分 261 = 马芬137 + 火腿扒124 ✓
猪柳麦满分 308 = 马芬137 + 猪柳126 + 芝士46 ✓
猪柳蛋麦满分 387 = 137 + 126 + 79 + 46 ✓
```
**每条估算都在输出里带区间、置信度和推导过程**,谁都能复核这个数是怎么来的。
---
## 👥 目标用户
| 用户 | 场景 | 本项目解决什么 |
| ----------------- | ----------------------------- | ------------------------------------- |
| **在做饮食管理的人** | 减脂 / 增肌 / 控糖期间仍会吃麦当劳,需要知道实际摄入 | App 只显示订单不显示热量;本项目把历史订单折算成卡路里 |
| **想复盘自己饮食习惯的人** | 「我这个月到底吃了多少麦当劳、热量多高」 | 一次列出全部历史订单的热量,并可按门店/时间观察 |
| **麦当劳高频用户** | 常点固定几样,想知道这些到底多少卡 | 常点商品逐项列出热量与匹配来源 |
| **营养 / 健康类内容创作者** | 需要可引用的数据,且必须说明数据来源与不确定性 | 每个数都标注来源层级;估算值附区间、置信度与推导过程 |
| **Agent 开发者** | 需要一个「两个数据源只能按名称对齐」的工程参考 | 五级级联匹配 + 假阳性防御 + 覆盖率披露,可作为 MCP 工程范式参考 |
> ⚠️ **重要边界**:本项目给出的是**估算参考**,不是医学或营养诊断依据。营养表未收录的商品按文中标注的方式估算并显式披露区间;覆盖率不足时数值是**下界**。请勿据此做严格的医学决策。
## 🧪 测试
**零依赖运行**——用标准库 `unittest`,clone 下来不用装任何东西就能验证:
```bash
PYTHONPATH=src python3 -m unittest discover -s tests -t tests # 107 passed
```
测试锁死的都是**实测踩过的坑**,不是设想的边界:
- 两个**假阳性回归**(`双层脆鸡堡`、`多汁柠柠` 必须永远被拒)
- **`相似度错答案更高`**(0.732 > 0.633)这个反直觉事实
- 含糖 / 无糖不可互配,大杯 / 中杯不可互配
- `蘸酱鸡球(甜酸酱风味)` **不是酱**,不能被酱规则吞掉 200+ kcal
- 剥掉括号后虽相等、但**含糖冲突必须拒绝**
- 覆盖率不足时必须标注下界
- 补充表**三条硬规矩**(推导 / 区间 / 来源)缺一即报错
- 品类正则写错**在加载期报错**而非静默失效
- 出厂的四份 JSON **真实加载**(最容易坏的不是代码而是数据)
### 验证状态(如实说明)
| 项 | 状态 |
| -------------------------------- | --------------------------- |
| 协议实现(握手 / MCP 调用 / 响应解析) | ✅ 107 项测试全绿 |
| 归一化与五级匹配 | ✅ 端到端跑通 |
| 真实端点 `https://mcp.mcd.cn` 真实账号数据 | ✅ **10/10 单完整覆盖,45/45 项命中** |
| 估算值(5 项) | ⚠️ 见上方区间与推导,**非官方数据** |
> 与本作品另一个方向不同:这个项目的核心逻辑**已经用真实账号数据验证过**,不是只跑通 mock。
---
## 🗂 项目结构
```
.
├── README.md # 本文件
├── CONTEST_DECLARATION.md # 参赛声明(官方原文,逐字节一致,请勿修改)
├── MCP_INTEGRATION.md # MCP Server / Tool / 调用流程 / 业务价值
├── mcp-config.example.json # 脱敏 MCP 配置示例(仅环境变量占位符)
├── SKILL.md # 通用 Agent Skill 入口
├── pyproject.toml # 打包配置(零第三方依赖)
├── LICENSE
├── src/mcd_order_calories/
│ ├── mcp_client.py # 零依赖 Streamable HTTP MCP 客户端
│ ├── payload.py # 解析服务端「字段说明 + JSON」混排返回
│ ├── nutrition.py # 营养表自定义分隔格式解析
│ ├── normalize.py # 归一化 + 硬约束(guard)提取
│ ├── categories.py # 品类规则(蘸酱 / 【美汁源】/ 厚松饼堡)
│ ├── supplements.py # 推导估算值与三条硬规矩校验
│ ├── matching.py # 五级级联匹配器(本项目核心)
│ ├── orders.py # 订单展开、覆盖率、缺口台账
│ ├── cli.py # 命令行入口
│ └── data/ # 四张人工维护表
├── tests/ # 107 项测试(零依赖 unittest)
└── docs/
└── data-maintenance.md # 数据表的维护流程
```
---
## 📌 合规说明
- `CONTEST_DECLARATION.md` 为官方原文,**文件名与内容均未改动**(SHA-256 `cd28101bcb29cc8fb24b372fe91ce6b42c4a1487a54e01f7ade0c8c6a84241b7`)。
- 仓库内不含任何真实 Token、密钥、账号凭证或他人个人信息;配置文件仅使用环境变量占位符。
- 项目为参赛者原创,仅通过麦当劳官方 MCP 接口访问数据,未抓取或内嵌任何非公开数据。
- **本项目只读**:仅调用 `order-list` 与 `list-nutrition-foods` 两个查询接口,不涉及任何下单、领券、抽奖等写操作。
- 所有热量数值仅供估算参考,**不构成医疗、营养或其他专业建议**;餐品信息以麦当劳官方渠道实时结果为准。营养表未收录的商品按文中标注的估算方式处理,会显式披露区间与推导。
> 本项目为「麦当劳程序员节创意开发大赛」参赛作品,由参赛者独立开发,**非麦当劳官方产品**。
## 📄 License
[MIT](./LICENSE)
连接信息
猜你喜欢
暂无推荐服务器