name: body-track description: 多用户身体追踪。用户报告吃了什么、发食物照片、问某个食物热量、汇报运动/手表数据、或想查看饮食记录时触发。首次使用自动进入 onboarding 收集身体数据。 argument-hint: [口述吃了什么/照片路径/食物热量查询/setup,或留空] allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Agent
身体追踪 · 饮食运动记录(多用户版)
概述
这是一个可以分享给其他人使用的身体追踪 skill。每个用户把这个文件夹复制到自己的 ~/.claude/skills/body-track/,首次使用时会自动引导设置。
数据目录
所有数据存放在 ~/body-track/:
~/body-track/
profile.json # 用户配置(身体数据、目标、飞书信息)
W{周数}_{日期}.md # 每周追踪文件
{年}-{月}_月报.md # 月报文件
{MMDD}/ # 食物照片文件夹第一步:检查是否已完成 Onboarding
每次触发时先检查 ~/body-track/profile.json 是否存在。
- 如果存在:读取 profile,直接进入「日常记录流程」
- 如果不存在:进入「Onboarding 流程」
Onboarding 流程(首次使用)
1. 检查飞书环境
先确认用户的 lark-cli 是否可用:
lark-cli auth status 2>&1- 如果未登录或未安装,告诉用户:
- 安装 lark-cli:
npm i -g @anthropic-ai/lark-cli(或按实际安装方式) - 配置飞书应用:
lark-cli config init - 登录:
lark-cli auth login - 需要的权限:
im:message(读消息)、im:resource(下载图片)、bitable:app(多维表格)
- 安装 lark-cli:
- 完成后再继续
2. 收集身体数据
通过对话逐步收集(必填项标 *):
| 项目 | 说明 | 用途 |
|---|---|---|
| * 性别 | 男/女 | BMR 计算 |
| * 年龄 | 周岁 | BMR 计算 |
| * 身高 (cm) | BMR 计算 | |
| * 体重 (kg) | 当前体重 | BMR 计算 + 追踪基线 |
| 体脂率 (%) | 如有体测报告 | 更精确的目标设定 |
| 骨骼肌 (kg) | 如有体测报告 | 增肌参考 |
| 健康状况 | 胰岛素抵抗、三高、甲状腺、PCOS 等 | 影响饮食建议方向 |
| 饮食偏好/禁忌 | 素食/清真/过敏/不吃什么 | 建议时避开 |
| 穿戴设备 | 有无手表/手环 | 是否有实际消耗数据 |
| * 目标 | 减脂/增肌/维持/改善饮食 | 决定热量策略 |
| 减脂目标 (kg) | 想减多少 | 计算减脂进度 |
一次性问完不友好,分 2-3 轮对话收集。先问基础(性别、年龄、身高、体重、目标),再问详情。
3. 计算个性化参数
用 Mifflin-St Jeor 公式:
- 男:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 + 5
- 女:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 - 161
默认 TDEE = BMR × 1.2(久坐)。如果有手表数据,用实际消耗。
热量目标:
- 减脂:TDEE - 300 ~ 500 kcal(不低于 BMR)
- 维持:TDEE
- 增肌:TDEE + 200 ~ 300 kcal
蛋白质目标:体重 × 1.2 ~ 1.6 g(减脂取高值,维持取低值)
4. 收集飞书信息
问用户:
- 饮食记录群的 chat_id:用户在飞书哪个群记录饮食?让用户提供群的 chat_id。获取方式:
# 搜索群聊 lark-cli im +chat-list --query "群名关键词" --as user - Webhook URL:让用户在群设置里添加自定义机器人,获取 webhook 地址(用于每日推送)
5. 创建飞书多维表格
用 lark-cli 自动创建:
# 在用户的云空间里创建多维表格
lark-cli base +create --name "身体追踪" --as user创建 4 张数据表(参考字段设计,用 lark-cli base 命令):
表1: 每日汇总
- 日期(日期)、摄入kcal(数字)、蛋白质g(数字)、碳水g(数字)、脂肪g(数字)
- 消耗kcal(数字)、热量差(数字)、体重(数字)
表2: 饮食明细
- 日期(日期)、餐次(单选:早餐/午餐/晚餐/加餐)、食物(文本)、份量(文本)
- 热量(数字)、蛋白质(数字)、脂肪(数字)、碳水(数字)
表3: 运动记录
- 日期(日期)、类别(单选:有氧/无氧)、运动名称(文本)、时长min(数字)、消耗kcal(数字)
表4: AI建议
- 分类(单选:总评/蛋白质/微量元素/运动/饮食推荐/减脂进度)、内容(文本)、数据依据(文本)、优先级(单选:高/中/低)
创建完成后记录 base_token 和各 table_id。
6. 保存 profile.json
mkdir -p ~/body-track写入 ~/body-track/profile.json:
{
"name": "用户名",
"chat_id": "oc_xxx",
"base_token": "xxx",
"table_ids": {
"daily": "tbl...",
"detail": "tbl...",
"exercise": "tbl...",
"advice": "tbl..."
},
"webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx",
"body": {
"gender": "female",
"age": 30,
"height_cm": 165,
"weight_kg": 60,
"body_fat_pct": null,
"skeletal_muscle_kg": null,
"conditions": [],
"diet_prefs": [],
"has_wearable": false
},
"targets": {
"goal": "fat_loss",
"target_weight_loss_kg": 5,
"daily_kcal_min": 1400,
"daily_kcal_max": 1600,
"protein_g": 80,
"bmr": 1300,
"tdee": 1560
},
"created_at": "2026-04-07",
"weight_start": 60,
"exercise_goal": "3-4次/周,每次20-30分钟"
}7. 设置每日推送
创建推送脚本 ~/body-track/body_push.py,逻辑:
- 读取最新的 dashboard HTML,提取未达标建议
- 每天 3 次轮播推送(早安/午间/晚间)
- 用 webhook 发送飞书卡片消息
- 卡片格式:标题栏 + 三列汇总指标 + 建议列表 + 运动提醒 + 累计赤字
推送脚本模板参考 ~/scripts/feishu-bot/body_push.py(如果存在的话),核心逻辑:
find_latest_dashboard()找最新 dashboard HTMLcollect_all_advice()提取红黄标签的建议extract_summary_metrics()提取汇总指标extract_exercise_summary()从周报提取运动数据build_card()构建飞书卡片 JSONsend_webhook()发送到用户的 webhook
推送脚本要从 ~/body-track/profile.json 读取 webhook_url 和 body_dir 路径。
设置定时任务(macOS launchd):
# 创建 plist 文件
cat > ~/Library/LaunchAgents/com.bodytrack.push.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.bodytrack.push</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>$HOME/body-track/body_push.py</string>
</array>
<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>30</integer></dict>
<dict><key>Hour</key><integer>19</integer><key>Minute</key><integer>0</integer></dict>
</array>
<key>StandardOutPath</key>
<string>$HOME/body-track/push.log</string>
<key>StandardErrorPath</key>
<string>$HOME/body-track/push.log</string>
</dict>
</plist>
EOF
# 加载
launchctl load ~/Library/LaunchAgents/com.bodytrack.push.plist8. Onboarding 完成
告诉用户:
- 数据目录:
~/body-track/ - 飞书看板:提供多维表格链接
- 每天 3 次推送已设置(8:00 / 12:30 / 19:00)
- 以后直接在群里发食物照片和口述,然后用
/body-track来同步记录
日常记录流程
读取 ~/body-track/profile.json 获取用户配置后:
数据来源:飞书对话
从用户的飞书群聊拉取消息:
lark-cli im +chat-messages-list --chat-id {profile.chat_id} \
--start "YYYY-MM-DDT00:00:00+08:00" --end "YYYY-MM-DDT23:59:59+08:00" \
--sort asc --as user --format json下载食物照片
cd /tmp && lark-cli im +messages-resources-download \
--message-id om_xxx --file-key img_xxx --type image --as user \
--output food_MMDD_序号.png下载后用 Read 工具查看图片,识别食物。照片归档到 ~/body-track/{MMDD}/。
处理照片
- HEIC 先转换:
sips -s format jpeg -Z 800 原文件 --out /tmp/food_photos/输出.jpg - 看照片时注意:手机作为尺寸参照物、吃前吃后对比判断实际吃了多少
- 如有营养标签,直接读取标签数据计算
营养估算
必须有份量依据(重量/个数/比例),不能凭"一碗""一杯"模糊估算。
每种食物估算以下营养素:
| 营养素 | 说明 |
|---|---|
| 热量 (kcal) | 总热量 |
| 蛋白质 (g) | |
| 碳水化合物 (g) | 其中标注糖的含量 |
| 脂肪 (g) | 其中标注饱和脂肪 |
| 膳食纤维 (g) | |
| 钠 (mg) | |
| 钙 (mg) | |
| 铁 (mg) | |
| 钾 (mg) | |
| 维生素C (mg) | 如有显著来源 |
| 维生素A (μg) | 如有显著来源 |
每餐汇总展示:热量 / 蛋白质 / 碳水 / 脂肪 四大项 + 值得注意的微量元素。
更新追踪文件
读取 profile.json 中的目标数据来做对比。
周文件(详细记录):
- 命名:
W{周数}_{起始日期}-{结束日期}.md(周一到周日,ISO 周数) - 每天记录:各餐内容+份量+热量+蛋白质+碳水+脂肪、消耗、热量差、体重
- 消耗:有手表数据用手表,无手表用 profile.targets.tdee
- 先读已有文件看格式,保持一致
- 如果当周文件不存在,新建(格式参考已有周文件或模板)
周文件模板(新建时使用):
# 第{周数}周 · {月}月{日}日 - {月}月{日}日
## 本周目标
- 热量摄入:{daily_kcal_min}-{daily_kcal_max} kcal/天
- 蛋白质:≥ {protein_g}g/天
- 运动:{exercise_goal}
---
## 周一 M/DD
| | 内容 | 份量 | 热量 | 蛋白质 | 脂肪 | 碳水 |
|---|---|---|---|---|---|---|
| 早餐 | ... | ... | ... | ... | ... | ... |
| **摄入合计** | | | **X kcal** | **Xg** | **Xg** | **Xg** |
| | 消耗 |
|---|---|
| 静息+活动 | {tdee} kcal |
| **消耗合计** | **{tdee} kcal** |
| 热量差 | 体重 | 能量值 |
|---|---|---|
| **X kcal** | — | /5 |月报文件(趋势总览):
- 命名:
{年}-{月}_月报.md - 包含:每日数据表、周均趋势对比、关键发现
- 每次更新周文件时同步更新月报
回复用户
- 当餐/当天的 热量 / 蛋白质 / 碳水 / 脂肪
- 如果当天还没吃完,告诉剩余预算(基于 profile 中的目标)
- 指出高热量低蛋白、高碳水、高钠的项目,但不说教不安慰
- 用户问某食物热量就直接回答数字,加上当天累计
- 如果用户有特殊健康状况(如胰岛素抵抗),相关建议只在用户问时提
能量和运动探测
每次交互时简短问一句:"今天能量怎么样?1-5"
- 如果能量 ≥ 3,问一句"想动一下吗?"
- 不强推,用户说不想就不追问
- 记录能量值和运动数据到周文件
注意事项
- 如实记录,不要为了让用户感觉好而低估热量
- 用户的所有个性化数据从 profile.json 读取,不要硬编码
- 保持简单直接
- 估算要诚实,不美化不安慰
$ARGUMENTS
name: body-track-dashboard description: 多用户身体追踪仪表盘。当用户说"更新仪表盘"、"同步看板"、"sync dashboard"、"更新飞书"时触发。同步身体数据到飞书多维表格并生成AI建议。 argument-hint: [留空自动同步,或指定日期范围] allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Agent
身体追踪仪表盘同步(多用户版)
前置:读取用户配置
cat ~/body-track/profile.json如果文件不存在,告诉用户先运行 /body-track setup 完成初始配置。
从 profile.json 获取:
base_token— 飞书多维表格 tokentable_ids.daily— 每日汇总表table_ids.detail— 饮食明细表table_ids.exercise— 运动记录表table_ids.advice— AI建议表webhook_url— 推送 webhooktargets.*— 用户目标(热量、蛋白质、减脂目标等)body.*— 用户身体数据(用于 AI 建议)
同步流程
第一步:读取本地数据
- 读取
~/body-track/W{周数}_{日期范围}.md获取最新饮食数据 - 确定哪些天的数据需要同步(对比飞书已有记录)
第二步:同步到飞书
用 lark-cli 命令同步数据:
# 插入/更新每日汇总
lark-cli base +record-upsert --base-token {base_token} \
--table-id {table_ids.daily} --as user \
--json '{"日期": <毫秒时间戳>, "摄入kcal": <值>, "蛋白质g": <值>, "碳水g": <值>, "脂肪g": <值>, "消耗kcal": <值>, "热量差": <值>, "体重": <值>}'
# 插入饮食明细
lark-cli base +record-upsert --base-token {base_token} \
--table-id {table_ids.detail} --as user \
--json '{"日期": <时间戳>, "餐次": "早餐", "食物": "xxx", "份量": "xxx", "热量": <值>, "蛋白质": <值>, "脂肪": <值>, "碳水": <值>}'
# 插入运动记录
lark-cli base +record-upsert --base-token {base_token} \
--table-id {table_ids.exercise} --as user \
--json '{"日期": <时间戳>, "运动名称": "散步", "类别": "有氧", "时长min": 20, "消耗kcal": 60}'日期时间戳:北京时间当天 00:00 的 Unix 毫秒时间戳。
第三步:生成 AI 建议
基于所有已有数据,结合用户 profile 中的身体数据和目标生成建议。
数据来源:
- 饮食记录(热量/宏量/微量营养素趋势)
- 用户身体数据(从 profile.body 读取)
- 用户目标(从 profile.targets 读取)
建议分类(每类一条记录写入 AI建议表):
- 总评:整体趋势、减脂进度、关键发现
- 蛋白质:达标率、规律分析、具体改善方案
- 微量元素:严重缺乏项、补充建议
- 运动:有氧/无氧推荐、实际完成度、目标对比
- 饮食推荐:高营养密度食物、需要避免的模式
- 如果用户有饮食偏好/禁忌(profile.body.diet_prefs),建议时要避开
- 减脂进度(如果用户目标是减脂):
- 1kg 脂肪 ≈ 7700 kcal
- 累计赤字 / 7700 = 已减脂肪(kg)
- 已减 / target_weight_loss_kg × 100% = 完成度
健康状况相关建议:
- 如果 profile.body.conditions 包含特殊健康状况(如胰岛素抵抗、PCOS、甲状腺等),在建议中考虑相关饮食原则
- 但不要过度强调,保持建议的实用性
第四步:更新AI建议到飞书
先删除旧的AI建议记录,再插入新的:
# 列出旧记录
lark-cli base +record-list --base-token {base_token} \
--table-id {table_ids.advice} --as user
# 删除旧记录
lark-cli base +record-delete --base-token {base_token} \
--table-id {table_ids.advice} --record-id <id> --as user
# 插入新建议
lark-cli base +record-upsert --base-token {base_token} \
--table-id {table_ids.advice} --as user \
--json '{"分类": "总评", "内容": "...", "数据依据": "...", "优先级": "高"}'第五步:生成本地 Dashboard HTML
在 ~/body-track/ 下生成周看板 HTML 文件(W{周数}_dashboard.html),用于:
- 本地浏览查看可视化图表
- 每日推送脚本从中提取建议
Dashboard 包含:
- 顶部汇总卡片:日均摄入(与目标对比)、日均蛋白质(与目标对比)、日均赤字、累计赤字
- 状态标记:good(达标绿)/ warn(接近黄)/ bad(差距大红)/ neutral(信息蓝)
- 图表区域:每日摄入vs消耗折线图、宏量营养素柱状图、营养素占比饼图、蛋白质达标柱状图、热量赤字趋势
- 每日卡片:每天的摄入/消耗/赤字/蛋白质/脂肪/碳水
- Insight 区域:宏量趋势分析(用 tag 标签标记 green/yellow/red)
- 微量营养素区域:各微量元素达标率卡片
HTML 使用 Chart.js CDN,暗色主题,响应式布局。具体样式参考已有的 dashboard HTML 文件格式(如果 ~/body-track/ 下有之前生成的)。
Insight 标签规则:
<span class="tag green">— 达标或良好<span class="tag yellow">— 接近目标但未达标<span class="tag red">— 严重不足或超标
重要:推送脚本依赖 insight-box 中的 tag 颜色来决定推送内容(跳过 green,推送 yellow 和 red),所以 HTML 格式必须保持一致。
第六步:回复用户
简要汇报:
- 同步了哪些天的数据
- 减脂进度(如适用):已减 X kg / 目标 Y kg = Z%
- 本周关键建议(1-2条最重要的)
- 提供飞书看板链接
注意事项
- 日期时间戳用北京时间 00:00 的毫秒时间戳
- 微量元素是估算值,标注"估算"
- AI建议要有数据依据,不空谈
- 所有数据都从 profile.json 读取,不要硬编码任何人的身体数据
- 默认总消耗(无手表数据时)= profile.targets.tdee
- 建议简短直接
$ARGUMENTS
body_push.py
View source (329 lines)
#!/usr/bin/env python3
"""
每日身体 AI 建议推送(卡片版 + 周期追踪)
每天推 3 次,每次挑不同的建议 + 吃药提醒 + 周期状态。
用法: python3 body_push.py [0|1|2] — 第几次推送(默认自动判断)
"""
import json
import os
import re
import sys
import glob
import html
import urllib.request
from datetime import date, timedelta
WEBHOOK_URL = "https://open.feishu.cn/open-apis/bot/v2/hook/5dd37bb4-0e98-4880-9b47-98c8c7823347"
BODY_DIR = os.path.expanduser("~/Desktop/12身体重启")
CYCLE_FILE = os.path.join(BODY_DIR, "cycle.json")
ITEMS_PER_PUSH = 3
HEADER_COLORS = ["blue", "turquoise", "violet"]
STATUS_COLOR = {"good": "green", "warn": "orange", "bad": "red", "neutral": "blue"}
# ─── 周期追踪 ───
def load_cycle():
if os.path.exists(CYCLE_FILE):
with open(CYCLE_FILE) as f:
return json.load(f)
return None
def cycle_status(cycle):
if not cycle:
return None
start = date.fromisoformat(cycle["cycle_start"])
today = date.today()
day = (today - start).days + 1
active = cycle.get("active_days", 21)
brk = cycle.get("break_days", 7)
total = active + brk
if day > total:
day = ((day - 1) % total) + 1
break_start = start + timedelta(days=active)
break_end = start + timedelta(days=total - 1)
if day <= 7:
pill = f"💊 优思明第{day}天/21天,记得吃药!"
phase = "📍 服药期第一周(激素稳定期)"
body_tip = "身体状态平稳,精力恢复中,适合正常饮食和运动"
elif day <= 14:
pill = f"💊 优思明第{day}天/21天,记得吃药!"
phase = "📍 服药期第二周(黄金期)"
body_tip = "状态最好的一周!运动表现好,蛋白质吸收效率高,适合加强度"
elif day <= active:
days_to_break = active - day
pill = f"💊 优思明第{day}天/21天,记得吃药!"
phase = f"📍 服药期第三周({break_start.month}/{break_start.day}进入停药期)"
if days_to_break <= 3:
body_tip = f"⚠️ 还有{days_to_break}天停药,可能已有PMS:情绪波动、腹胀、水肿、食欲增加。体重涨1-2kg是水分,不是胖了!"
else:
body_tip = "可能出现轻微水钠潴留,体重微涨属正常。注意补钾(香蕉/土豆),不要因为体重焦虑而节食"
else:
break_day = day - active
next_start = break_end + timedelta(days=1)
pill = f"💊 停药期第{break_day}天/{brk}天(不用吃优思明)"
phase = f"📍 停药期({next_start.month}/{next_start.day}开新一轮)"
if break_day <= 2:
body_tip = "撤退性出血通常停药2-4天后出现。可能疲劳、情绪低,运动散步瑜伽即可,允许多吃100-200kcal"
elif break_day <= 5:
body_tip = "🩸 出血期,铁流失增加——多吃红肉/菠菜/配维C。运动量可以降低,不要硬撑"
else:
body_tip = f"出血接近尾声,{next_start.month}/{next_start.day}开始新一轮服药,记得准备好药"
return {"day": day, "pill": pill, "phase": phase, "body_tip": body_tip}
# ─── 数据提取 ───
def find_latest_dashboard():
pattern = os.path.join(BODY_DIR, "W*_dashboard.html")
files = glob.glob(pattern)
if not files:
main = os.path.join(BODY_DIR, "dashboard.html")
return main if os.path.exists(main) else None
files.sort(key=os.path.getmtime)
return files[-1]
def find_latest_weekly():
pattern = os.path.join(BODY_DIR, "W*_0*.md")
files = glob.glob(pattern)
if not files:
return None
files.sort(key=os.path.getmtime)
return files[-1]
def strip_html(text):
text = html.unescape(text)
return re.sub(r'<[^>]+>', '', text).strip()
def extract_summary_metrics(filepath):
"""从 dashboard 提取顶部汇总指标"""
if not filepath:
return {}
with open(filepath) as f:
content = f.read()
metrics = {}
for card in re.finditer(
r'<div class="summary-card\s+(\w+)">\s*'
r'<div class="label">(.+?)</div>\s*'
r'<div class="value"[^>]*>(.+?)</div>\s*'
r'<div class="unit">(.+?)</div>\s*'
r'<div class="target">(.+?)</div>',
content, re.DOTALL
):
status, label, value, unit, target = [strip_html(g) for g in card.groups()]
metrics[label] = {"value": value, "unit": unit, "target": target, "status": status}
return metrics
def collect_all_advice(filepath):
with open(filepath) as f:
content = f.read()
items = []
for box in re.finditer(
r'<div class="insight-box">\s*<h3>(.+?)</h3>\s*<ul>(.*?)</ul>\s*</div>',
content, re.DOTALL
):
for li in re.finditer(r'<li>(.*?)</li>', box.group(2), re.DOTALL):
raw = li.group(1)
if 'tag green' in raw:
continue
text = strip_html(raw)
color = "red" if 'tag red' in raw else "yellow"
items.append({"text": text, "color": color})
return items
def extract_exercise_summary(filepath):
with open(filepath) as f:
content = f.read()
today = date.today()
year = today.year
cutoff = today - timedelta(days=7)
exercise_days = 0
total_active = 0
for section in re.split(r'(?=^## 周[一二三四五六日]\s+\d+/\d+)', content, flags=re.MULTILINE):
header = re.match(r'## 周[一二三四五六日]\s+(\d+)/(\d+)', section)
if not header:
continue
try:
d = date(year, int(header.group(1)), int(header.group(2)))
except ValueError:
continue
if d < cutoff or d > today:
continue
m = re.search(r'含(?:运动|活动)(\d+)', section)
if m:
exercise_days += 1
total_active += int(m.group(1))
goal = "3-4次/周"
if exercise_days >= 3:
return f"本周运动{exercise_days}次,活动消耗{total_active}kcal,继续保持!"
elif exercise_days >= 1:
return f"本周运动{exercise_days}次(目标{goal}),还差{3 - exercise_days}次,今天动一动?"
else:
return f"本周还没运动(目标{goal}),找20分钟动起来!"
def pick_items(all_items, push_index):
if not all_items:
return []
n = len(all_items)
day_offset = date.today().toordinal()
start = (day_offset * 3 + push_index) * ITEMS_PER_PUSH % n
return [all_items[(start + i) % n] for i in range(ITEMS_PER_PUSH)]
# ─── 卡片构建 ───
def build_card(cycle_info, metrics, diet_items, exercise_line, push_index):
today = date.today()
label = ["早安", "午间", "晚间"][push_index]
elements = []
# 周期状态
if cycle_info:
elements.append({
"tag": "markdown",
"content": f"{cycle_info['pill']}\n{cycle_info['phase']}\n{cycle_info['body_tip']}"
})
elements.append({"tag": "hr"})
# 汇总指标(三列)
keys_order = ["日均摄入", "日均蛋白质", "日均赤字"]
columns = []
for key in keys_order:
m = metrics.get(key)
if not m:
continue
color = STATUS_COLOR.get(m["status"], "blue")
columns.append({
"tag": "column",
"width": "weighted",
"weight": 1,
"vertical_align": "center",
"elements": [{
"tag": "markdown",
"content": f"**{key}**\n<font color='{color}'>{m['value']}</font> {m['unit']}\n{m['target']}"
}]
})
if columns:
elements.append({
"tag": "column_set",
"flex_mode": "bisect",
"background_style": "grey",
"columns": columns,
})
elements.append({"tag": "hr"})
# 饮食建议
if diet_items:
elements.append({"tag": "markdown", "content": "**📋 今日建议**"})
for item in diet_items:
icon = "🔴" if item["color"] == "red" else "🟡"
text = item["text"]
parts = text.split(" ", 1)
if len(parts) == 2 and len(parts[0]) <= 15:
md = f"{icon} **{parts[0]}** — {parts[1]}"
else:
md = f"{icon} {text}"
elements.append({"tag": "markdown", "content": md})
elements.append({"tag": "hr"})
# 运动
if exercise_line:
elements.append({"tag": "markdown", "content": f"🏃 {exercise_line}"})
# 累计赤字
cum = metrics.get("累计赤字")
if cum:
elements.append({
"tag": "markdown",
"content": f"📊 累计赤字 **{cum['value']}** {cum['unit']} | {cum['target']}"
})
return {
"msg_type": "interactive",
"card": {
"header": {
"template": HEADER_COLORS[push_index],
"title": {"tag": "plain_text", "content": f"🤖 {label}提醒 · {today.month}/{today.day}"}
},
"elements": elements,
}
}
# ─── 发送 ───
def send_webhook(card_payload):
payload = json.dumps(card_payload).encode()
req = urllib.request.Request(
WEBHOOK_URL, data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
print(f"[推送结果] {result}")
return result
except Exception as e:
print(f"[推送失败] {e}")
return None
def auto_push_index():
from datetime import datetime
h = datetime.now().hour
if h < 11:
return 0
elif h < 16:
return 1
else:
return 2
def main():
push_index = int(sys.argv[1]) if len(sys.argv) > 1 else auto_push_index()
cycle_info = cycle_status(load_cycle())
dashboard = find_latest_dashboard()
all_advice = collect_all_advice(dashboard) if dashboard else []
metrics = extract_summary_metrics(dashboard)
weekly = find_latest_weekly()
exercise_line = extract_exercise_summary(weekly) if weekly else None
diet_items = pick_items(all_advice, push_index)
card = build_card(cycle_info, metrics, diet_items, exercise_line, push_index)
# 日志
for el in card["card"]["elements"]:
if el.get("tag") == "markdown":
print(el["content"])
print()
send_webhook(card)
if __name__ == "__main__":
main()