Files
eink-push/data.py
YANG JIANKUAN b1a99a0e6b feat: 今日实时新增团购单量/流水字段,E1002 改五列竖排版式
今日实时由三列(单量/流水/毛利)扩展为五列,新增团购单量与团购流水;小屏
仍只显三列(跳过 wide 列),宽版 E1002 从「值 F36+右侧叠放环比同比」改为
四行竖排(名/值/环比/同比),避免五列每列 157px 放不下的问题。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:12:01 +08:00

403 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据层 —— 汇总仪表盘所需的全部数据。
橘喵今日经营 + 近30天趋势来自 jm_apijm-devops 后端统计接口,免鉴权 GET
天气来自 weather_api和风天气定位南京·江宁。日期/时间用真实系统时间(统一按 config.TZ_NAME 时区)。
任何接口失败时相关数据以占位符 "--" 呈现,不回退 mock。
两种调用形态:
- 本机脚本main.pyget_dashboard_data() —— 带文件缓存(天气/趋势/BTC用量读本地快照
- 图片服务server.py直接用各 `*_fetch()` 纯拉取函数 + `_usage(now, raw=<推送数据>)`,不落盘。
"""
import json
from datetime import datetime
from zoneinfo import ZoneInfo
import config
import jm_api
import weather_api
import usage_local
import btc_api
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
TZ = ZoneInfo(config.TZ_NAME)
def now_tz():
"""当前时刻(带 config.TZ_NAME 时区)。"""
return datetime.now(TZ)
def _local(ts):
"""epoch 秒 → 该时区的 datetime。"""
return datetime.fromtimestamp(ts, TZ)
TREND_DAYS = 30 # 近 N 天趋势
FIVE_HOUR, SEVEN_DAY = 5 * 3600, 7 * 86400 # 两个滚动窗口长度(秒),用于算「时间已流逝%」
def _read_weather_cache():
try:
with open(config.WEATHER_CACHE_PATH, encoding="utf-8") as f:
return json.load(f) # {"ts": float, "data": {...}}
except Exception:
return None
def _write_weather_cache(ts, data):
try:
with open(config.WEATHER_CACHE_PATH, "w", encoding="utf-8") as f:
json.dump({"ts": ts, "data": data}, f, ensure_ascii=False)
except Exception as e:
print(f"[警告] 天气缓存写入失败:{e}")
def _read_trend_cache():
try:
with open(config.TREND_CACHE_PATH, encoding="utf-8") as f:
return json.load(f) # {"date": "YYYY-MM-DD", "data": {...}}
except Exception:
return None
def _write_trend_cache(date_key, data):
try:
with open(config.TREND_CACHE_PATH, "w", encoding="utf-8") as f:
json.dump({"date": date_key, "data": data}, f, ensure_ascii=False)
except Exception as e:
print(f"[警告] 趋势缓存写入失败:{e}")
def _trend(now):
"""近30天趋势按日期缓存recent-days 每天只更新一次。
当天已有缓存(且有数据)→ 直接复用,不发请求;当天还没有 → 拉取并写缓存。
拉取失败时沿用任意旧缓存(哪怕非当天),仍无缓存才回退空。
API 降序 → 升序:左旧右新。
"""
date_key = now.strftime("%Y-%m-%d")
cached = _read_trend_cache()
if cached and cached.get("date") == date_key and cached.get("data", {}).get("series"):
return cached["data"]
try:
asc = list(reversed(jm_api.get_recent_days(TREND_DAYS)))
trend = {
"dates": [d["date"][5:].replace("-", "/") for d in asc], # yyyy-MM-dd → MM/DD
"series": [
{"k": "单量", "style": "solid", "data": [int(d["orderCount"]) for d in asc]},
{"k": "流水", "style": "dashed", "data": [float(d["payAmount"]) for d in asc]},
{"k": "毛利", "style": "dotted", "data": [float(d["grossProfit"]) for d in asc]},
],
}
_write_trend_cache(date_key, trend)
return trend
except Exception as e:
print(f"[警告] 近30天趋势拉取失败{e}")
if cached and cached.get("data", {}).get("series"): # 失败沿用旧缓存,避免趋势图整片 "--"
print("[警告] 沿用上次趋势缓存")
return cached["data"]
return {"dates": [], "series": []}
def _weather(now):
"""和风天气(南京·江宁),带 WEATHER_TTL 文件缓存(本机脚本用)。
缓存未过期 → 直接复用,不发请求(每分钟刷新时省掉 14/15 次天气调用)。
过期 → 重新拉取并写缓存;全部接口失败时沿用上次缓存,仍无缓存才回退占位 "--"
"""
ts = now.timestamp()
cached = _read_weather_cache()
if cached and ts - cached.get("ts", 0) < config.WEATHER_TTL:
return cached["data"]
out, ok = weather_fetch()
if ok:
_write_weather_cache(ts, out)
return out
if cached: # 全部失败:沿用上次缓存,避免天气区整片 "--"
print("[警告] 天气全部拉取失败,沿用上次缓存")
return cached["data"]
return out
def weather_fetch():
"""实时拉取天气(不读不写缓存)→ weather dict是否至少一个接口成功。服务端每次请求直接调用。"""
out = {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""}
ok = False
try:
w = weather_api.get_now()
out["cond"] = w.get("text") or "--"
out["temp"] = f"{w['temp']}°"
out["icon"] = w.get("icon") or "" # 和风图标代码,供 renderer 映射图标
ok = True
except Exception as e:
print(f"[警告] 实时天气拉取失败:{e}")
try:
today = weather_api.get_today()
out["range"] = f"{today['tempMin']}°~{today['tempMax']}°"
ok = True
except Exception as e:
print(f"[警告] 今日温区拉取失败:{e}")
return out, ok
# ---------- 格式化 ----------
def _fmt_count(n):
return f"{int(n):,}" # 千分位,如 1,284
def _fmt_money(yuan):
"""元金额 → 紧凑展示≥1万显示「X.XX万」(两位小数),否则取整元。"""
v = float(yuan)
return f"{v / 10000:.2f}" if abs(v) >= 10000 else f"{v:.0f}"
def _fmt_rate(rate):
"""增长率字符串 → (方向, 文本)。null/空 → (None, '--'),方向 None 表示不画三角。
数值统一两位小数;整数部分不足两位用前导 0 补齐(6.5→06.50),便于环比/同比上下对齐;
三/四位数(≥100%)原样完整输出不截断。"""
if rate is None or rate == "":
return (None, "--")
v = float(rate)
return ("down" if v < 0 else "up", f"{abs(v):05.2f}%")
def _col(k, v, wow, yoy, wide=False):
"""一列指标。wide=True 表示仅宽版800×480展示400×300 小屏三列放不下、渲染时跳过。"""
return {"k": k, "v": v, "hb": _fmt_rate(wow), "tb": _fmt_rate(yoy), "wide": wide}
def _placeholder_col(k, wide=False):
return {"k": k, "v": "--", "hb": (None, "--"), "tb": (None, "--"), "wide": wide}
# 今日实时五列:(标签、接口字段前缀、格式化、是否仅宽版)。字段前缀 base 对应 today{Base} / {base}WowRate / {base}YoyRate
# 见 docs/首页看板接口文档-20260911.md。团购两列为新增字段旧后端无此键 → 占位 "--"(不报错、不影响其余列)。
REALTIME_COLS = (
("探店单量", "orderCount", _fmt_count, False),
("探店流水", "payAmount", _fmt_money, False),
("团购单量", "groupbuyOrderCount", _fmt_count, True),
("团购流水", "groupbuyPayAmount", _fmt_money, True),
("毛利", "grossProfit", _fmt_money, False), # 口径已含团购毛利(接口合计,未拆分)
)
def _col_from(rt, k, base, fmt, wide):
cap = base[0].upper() + base[1:]
v = rt.get(f"today{cap}")
if v is None or v == "":
return _placeholder_col(k, wide)
return _col(k, fmt(v), rt.get(f"{base}WowRate"), rt.get(f"{base}YoyRate"), wide)
# ---------- 橘喵业务数据 ----------
def mao_fetch(now, with_trend=True):
"""橘喵今日经营实时接口with_trend=False 时不拉近 30 天趋势(服务端与宽版都不用它,且趋势有文件缓存)。
返回mao dict实时接口是否成功"""
upd = now.strftime("%H:%M")
ok = True
# 今日经营realtime
try:
rt = jm_api.get_realtime()
upd = (rt.get("dataTime") or "")[11:16] or upd
cols = [_col_from(rt, k, base, fmt, wide) for k, base, fmt, wide in REALTIME_COLS]
except Exception as e:
print(f"[警告] 今日经营拉取失败:{e}")
cols = [_placeholder_col(k, wide) for k, _, _, wide in REALTIME_COLS]
ok = False
# 近30天趋势按日期缓存每天只拉一次
trend = _trend(now) if with_trend else {"dates": [], "series": []}
return {"upd": upd, "cols": cols, "trend": trend}, ok
def _mao(now):
return mao_fetch(now)[0]
# ---------- 比特币 K 线 ----------
def _read_btc_cache():
try:
with open(config.BTC_CACHE_PATH, encoding="utf-8") as f:
return json.load(f) # {"ts": float, "data": {...}}
except Exception:
return None
def _write_btc_cache(ts, data):
try:
with open(config.BTC_CACHE_PATH, "w", encoding="utf-8") as f:
json.dump({"ts": ts, "data": data}, f, ensure_ascii=False)
except Exception as e:
print(f"[警告] BTC 缓存写入失败:{e}")
def _btc(now):
"""比特币 K 线(宽版底部面板;周期 BTC_BAR、根数 BTC_LIMIT带 BTC_TTL 文件缓存;
拉取失败沿用旧缓存,仍无缓存回退空 candles。
结构契约renderer 依赖):{symbol, bar, src, candles:[{t, o, h, l, c}], last, chg, chg_label},其中 t 为轴标签文本;
last 为最新一根当前未收盘的收盘价chg 为涨跌幅(%)——时 K 取相对 24 根前24h收盘、不足则相对首根
日 K 取相对上一根收盘chg_label 说明口径("24h"/"1d");涨=绿 / 跌=红 由 renderer 按 c≥o 判定。"""
ts = now.timestamp()
bar = config.BTC_BAR
cached = _read_btc_cache()
if cached and ts - cached.get("ts", 0) < config.BTC_TTL and cached.get("data", {}).get("bar") == bar:
return cached["data"]
try:
out = btc_fetch()
_write_btc_cache(ts, out)
return out
except Exception as e:
print(f"[警告] BTC K 线拉取失败:{e}")
if cached:
print("[警告] 沿用上次 BTC 缓存")
return cached["data"]
return btc_empty()
def btc_empty():
return {"symbol": "BTC/USDT", "bar": config.BTC_BAR, "src": "", "candles": [], "last": None, "chg": None, "chg_label": ""}
def btc_fetch():
"""实时拉取 BTC K 线并整理成渲染结构(不读不写缓存);失败抛异常。服务端每次请求直接调用。"""
bar = config.BTC_BAR
src, rows = btc_api.get_candles(bar, config.BTC_LIMIT)
fmt = "%m/%d %H:%M" if bar == "1H" else "%m/%d"
candles = [{"t": _local(r["ts"]).strftime(fmt), "o": r["o"], "h": r["h"], "l": r["l"], "c": r["c"]} for r in rows]
last = rows[-1]["c"]
if bar == "1H":
base = rows[-25]["c"] if len(rows) >= 25 else rows[0]["c"]
chg_label = "24h" if len(rows) >= 25 else f"{len(rows) - 1}h"
else:
base, chg_label = rows[-2]["c"], "1d"
return {"symbol": "BTC/USDT", "bar": bar, "src": src, "candles": candles,
"last": last, "chg": (last - base) / base * 100 if base else 0.0, "chg_label": chg_label}
# ---------- Claude Code 用量 ----------
def _parse_reset_ts(s):
"""resets_at → epoch 秒。兼容三种epoch 数字Claude Code stdin 给的就是 epoch
ISO 时间串(含结尾 'Z')、空。失败返回 None。"""
if s is None or s == "":
return None
s = str(s)
if s.replace(".", "", 1).isdigit(): # 纯数字 → 当作 epoch 秒
return float(s)
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
except Exception:
return None
def _window_time_pct(resets_at, window, now):
"""该滚动窗口「已流逝时间百分比」= (window 距重置剩余) / window ×100。失败返回 None。"""
reset = _parse_reset_ts(resets_at)
if reset is None:
return None
remaining = max(0, min(window, reset - now.timestamp()))
return int(round((window - remaining) / window * 100))
def _fmt_reset(resets_at, window):
"""重置时刻 → 文本≤1 天的窗口只给 HH:MM更长的窗口带 MM/DD。解析失败返回 None。"""
ts = _parse_reset_ts(resets_at)
if ts is None:
return None
dt = _local(ts)
return dt.strftime("%H:%M") if window <= 86400 else dt.strftime("%m/%d %H:%M")
# Claude 用量窗口快照键短标签长标签窗口长度秒scoped
# 短标签 k 给 400x300 小屏(标签列只有 26px长标签 label 给 800x480 宽版,沿用 statusline 的写法
# all 5h / all 7d / fable 7d小写开头。scoped=True 为「按模型限定」的额度Fable 7d来自
# /api/oauth/usage 的 limits[],由 statusline 写入快照 seven_day_fable小屏放不下第三行只在宽版展示。
USAGE_WINDOWS = (
("five_hour", "5h", "all 5h", FIVE_HOUR, False),
("seven_day", "7d", "all 7d", SEVEN_DAY, False),
("seven_day_fable", "Fable 7d", "fable 7d", SEVEN_DAY, True),
)
# ChatGPT 用量窗口键名沿用同一套约定five_hour / seven_day / seven_day_codex推送端照此结构提供原始数据即可。
GPT_WINDOWS = (
("five_hour", "5h", "all 5h", FIVE_HOUR, False),
("seven_day", "7d", "all 7d", SEVEN_DAY, False),
("seven_day_codex", "Codex 7d", "codex 7d", SEVEN_DAY, True),
)
def usage_from_raw(now, raw, windows, title):
"""原始快照 → 渲染结构Claude / ChatGPT 通用)。
raw 形如 {five_hour:{utilization, resets_at}, seven_day:{...}, <scoped 键>:{...}|null, updated_at?}
utilization 为已用百分比resets_at 为 epoch 秒或 ISO缺哪个窗口哪个显示 "--"
time_pct时间已流逝%)与 pace 按传入的 now **现算**——所以推送端只需推原始快照,服务端渲染时 pace 仍随时钟准确推进。
结构契约renderer 依赖):{title, warn, ok, pace_tol, updated?, bars:[{k, label, pct(0~100 或 None), time_pct?, reset?, scoped}]}
pct ≥ warn 时该条进度条与百分比转红(宽版另有 pct ≤ ok 绿档pace = pct time_pct>0 超前↑ / <0 节余↓,宽版按 ±pace_tol 分三色);
reset 为重置时刻文本updated 为快照落盘时刻 HH:MM活跃使用时持续刷新空闲时停在最后一次"""
raw = raw or {}
bars = []
for key, k, label, win, scoped in windows:
seg = raw.get(key) or {}
util = seg.get("utilization")
pct = int(round(float(util))) if util is not None else None
bar = {"k": k, "label": label, "pct": pct, "scoped": scoped}
tp = _window_time_pct(seg.get("resets_at"), win, now)
if pct is not None and tp is not None:
bar["time_pct"] = tp
rs = _fmt_reset(seg.get("resets_at"), win)
if rs:
bar["reset"] = rs
bars.append(bar)
out = {"title": title, "warn": config.USAGE_WARN, "ok": config.USAGE_OK, "pace_tol": config.PACE_TOL, "bars": bars}
upd = _parse_reset_ts(raw.get("updated_at"))
if upd:
out["updated"] = _local(upd).strftime("%H:%M")
return out
def _usage(now, raw=None):
"""Claude Code 用量5h / 7d / Fable 7d。raw 为 None 时读本地 statusline 快照(见 usage_local.py**不发请求**
服务端传入推送来的原始快照。utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值Fable 行来自其转存的 API limits"""
if raw is None:
raw = {}
try:
raw = usage_local.get_usage()
except Exception as e:
print(f"[警告] Claude 用量读取失败:{e}")
return usage_from_raw(now, raw, USAGE_WINDOWS, "Claude Usage")
def gpt_mock_raw(now):
"""ChatGPT 原始快照的 mock尚无真实数据源时用刻意覆盖宽版三档配色——
5h 88%≥warn 红、7d 71%、Codex 7d 22%≤ok 绿scoped与左侧 fable 行对称)。"""
t = now.timestamp()
return {"five_hour": {"utilization": 88, "resets_at": t + 2 * 3600},
"seven_day": {"utilization": 71, "resets_at": t + 3 * 86400},
"seven_day_codex": {"utilization": 22, "resets_at": t + 3 * 86400}}
def _usage_gpt(now, raw=None):
"""ChatGPT 用量。raw 为推送来的原始快照(结构同 gpt_mock_raw为 None 时用 mock 并标 mock=True
renderer 在标题右侧标「示例数据」)。接入真实数据源时由推送端提供 raw本函数无需改动。"""
mock = raw is None
out = usage_from_raw(now, gpt_mock_raw(now) if mock else raw, GPT_WINDOWS, "ChatGPT Usage")
if mock:
out["mock"] = True
return out
def get_dashboard_data(now=None):
"""返回渲染所需的完整数据字典(本机脚本形态:带文件缓存,用量读本地快照)。"""
now = now or now_tz()
return {
"date": {
"greg": now.strftime("%Y/%m/%d"),
"week": WEEK_CN[now.weekday()],
"time": now.strftime("%H:%M"),
},
"weather": _weather(now),
"mao": _mao(now),
"usage": _usage(now),
"usage_gpt": _usage_gpt(now),
"btc": _btc(now),
}