Files
eink-push/html_renderer.py
YANG JIANKUAN 7378d6bb88 feat: AI 用量改账户级本机采集(Claude 钥匙串/Codex RPC)+宽版 7d 合并行
- Claude:复用 Claude Code 钥匙串登录态直调 /api/oauth/usage,零依赖 statusline 与 Claude Code 进程;只读不刷新 token
- Codex:经 codex app-server JSON-RPC 读 ChatGPT 订阅的 Codex 额度池(普通聊天额度无账户级途径,右块改名 Codex Usage)
- 两路采集共用 TTL 缓存+失败退避(CLAUDE_USAGE_TTL/CODEX_USAGE_TTL,语义同 WEATHER_TTL),推送端两路独立上报
- E1002:Claude 7d 与 fable 7d 合并为一行(单边框内上下对半实心,数字取较大者)、标签去 all 前缀、空值按 00% 00 占位等宽
- E1002/网页版:今日实时环比同比改涨绿跌红,与 K 线一致

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 14:08:21 +08:00

185 lines
11 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 -*-
"""
方案二800x480 的**现代网页**版仪表盘server.py 的 HTML_PATH 每次请求现生成),供 SenseCraft HTML 控件用无头浏览器截图。
与 PNG 版renderer_e1002的差别
- 不限点阵字体系统中文字体栈PingFang / Microsoft YaHei / Noto Sans SC+ Google Fonts 的 Noto Sans SC 兜底
(平台截图机若无中文字体会掉字,网络字体是保险;能否加载取决于其出网能力)。
- 纯 HTML+CSS+内联 SVG**不依赖 JavaScript**(平台是否执行 JS 未证实),数据全部服务端写死进 HTML。
- 颜色只用六色纯值 #000/#fff/#f00/#0f0黄/蓝未用),抗锯齿边缘交给平台抖动——这正是要和 PNG 版对比的点。
- 版式与 PNG 版同构:顶部时钟/天气;左 Claude Usage、右 Codex Usage橘喵今日实时三列BTC 时 KSVG
- 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑pace ≥+tol 红 / ≤tol 绿 / 其间黑;环比同比 涨绿 跌红(与 K 线一致K 线 涨绿 跌红。
"""
from html import escape as _e
RED, GREEN, BLACK = "#f00", "#0f0", "#000"
_CSS = """
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:800px;height:480px;overflow:hidden;background:#fff;color:#000;
font-family:"PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",Arial,sans-serif;
-webkit-font-smoothing:antialiased}
.page{width:800px;height:480px;padding:12px 16px 10px;display:flex;flex-direction:column;gap:6px}
.hd{display:flex;justify-content:space-between;align-items:flex-end;height:92px;border-bottom:2px solid #000;padding-bottom:6px}
.clock{font-size:72px;font-weight:800;line-height:1;letter-spacing:-2px;font-variant-numeric:tabular-nums}
.date{font-size:15px;margin-top:4px;color:#000}
.wx{text-align:right;display:flex;align-items:flex-end;gap:14px}
.wx .t{font-size:44px;font-weight:800;line-height:1}
.wx .c{font-size:15px;line-height:1.45}
.wx .loc{font-size:13px;color:#000}
.pin{display:inline-block;width:9px;height:9px;border-radius:50% 50% 50% 0;background:#f00;transform:rotate(-45deg);margin-right:4px;vertical-align:-1px}
.row{display:flex;gap:16px}
.card{flex:1;min-width:0}
.card h3{font-size:14px;font-weight:700;display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px}
.card h3 em{font-style:normal;font-weight:400;font-size:12px}
.card h3 b::before{content:"";display:inline-block;width:9px;height:9px;background:#000;margin-right:6px;vertical-align:-1px}
.u{display:grid;grid-template-columns:64px 1fr 58px 44px;align-items:center;column-gap:8px;height:26px}
.u .k{font-size:13px;font-weight:700;font-family:"SF Mono",Menlo,Consolas,monospace}
.bar{height:11px;border:1.5px solid #000;position:relative;background:#fff}
.bar i{position:absolute;left:0;top:0;bottom:0;background:#000}
.u .p{font-size:19px;font-weight:800;text-align:right;font-variant-numeric:tabular-nums;line-height:1}
.u .pace{font-size:13px;font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
.tri{display:inline-block;width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;vertical-align:1px;margin-right:2px}
.tri.up{border-bottom:8px solid currentColor}.tri.dn{border-top:8px solid currentColor}
.rt{border-top:2px solid #000;border-bottom:2px solid #000;padding:6px 0 4px}
.rt .cols{display:flex}
.rt .col{flex:1;text-align:center;border-left:1px dashed #000;padding:2px 0}
.rt .col:first-child{border-left:0}
.rt .name{font-size:13px;margin-bottom:2px}
.rt .grp{display:inline-flex;align-items:center;gap:12px}
.rt .v{font-size:36px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums}
.rt .cmp{text-align:left;font-size:12px;line-height:1.5;font-variant-numeric:tabular-nums}
.btc h3{margin-bottom:2px}
.btc svg{display:block}
"""
def _tier_bar(bar, usage):
"""用量条颜色三档 绿/黑/红(与 renderer_e1002 一致)。返回 (填充/边框色, 百分比文字色),两者同色。"""
pct = bar.get("pct")
if not isinstance(pct, (int, float)):
return BLACK, BLACK
c = RED if pct >= usage.get("warn", 80) else GREEN if pct <= usage.get("ok", 30) else BLACK
return c, c
def _tier_pace(diff, usage):
tol = usage.get("pace_tol", 10)
return RED if diff >= tol else GREEN if diff <= -tol else BLACK
def _usage_card(usage):
note = "示例数据" if usage.get("mock") else (f'上次更新 {usage["updated"]}' if usage.get("updated") else "")
rows = []
for b in usage.get("bars", []):
pct = b.get("pct")
ok = isinstance(pct, (int, float))
fill, ptxt = _tier_bar(b, usage)
width = max(0, min(100, int(pct))) if ok else 0
pace = ""
if ok and "time_pct" in b:
diff = int(pct) - int(b["time_pct"])
col = _tier_pace(diff, usage)
tri = '<i class="tri up"></i>' if diff > 0 else ('<i class="tri dn"></i>' if diff < 0 else "")
pace = f'<span class="pace" style="color:{col}">{tri}{abs(diff):02d}</span>'
else:
pace = '<span class="pace"></span>'
rows.append(
f'<div class="u"><span class="k">{_e(b.get("label") or b["k"])}</span>'
f'<span class="bar" style="border-color:{fill}"><i style="width:{width}%;background:{fill}"></i></span>'
f'<span class="p" style="color:{ptxt}">{f"{int(pct):02d}%" if ok else "--"}</span>{pace}</div>')
return (f'<section class="card"><h3><b>{_e(usage.get("title", "Usage"))}</b><em>{_e(note)}</em></h3>'
+ "".join(rows) + "</section>")
def _cmp(label, pair):
direction, val = pair
col = GREEN if direction == "up" else RED if direction == "down" else BLACK
tri = '<i class="tri up"></i>' if direction == "up" else ('<i class="tri dn"></i>' if direction == "down" else "")
return f'<div>{_e(label)} <span style="color:{col}">{tri}{_e(val)}</span></div>'
def _realtime(mao):
cols = "".join(
f'<div class="col"><div class="name">{_e(c["k"])}</div><div class="grp"><span class="v">{_e(c["v"])}</span>'
f'<span class="cmp">{_cmp("环比", c["hb"])}{_cmp("同比", c["tb"])}</span></div></div>'
for c in mao["cols"])
return (f'<section class="rt card"><h3><b>橘喵·今日实时</b><em>上次更新 {_e(mao["upd"])}</em></h3>'
f'<div class="cols">{cols}</div></section>')
def _fmt_price(v):
return f"{v:,.0f}" if abs(v) >= 1000 else f"{v:.2f}"
def _btc(btc, W=768, H=118):
"""BTC K 线 → 内联 SVG涨绿跌红坐标逻辑同 renderer_e1002._btc_chart。"""
btc = btc or {}
candles = btc.get("candles") or []
kind = "时K" if btc.get("bar", "1H") == "1H" else "日K"
head = f'<b>{_e(btc.get("symbol") or "BTC/USDT")}·{kind}</b>'
right = ""
if candles and btc.get("last") is not None:
chg = btc.get("chg") or 0.0
col = GREEN if chg >= 0 else RED
tri = '<i class="tri up"></i>' if chg >= 0 else '<i class="tri dn"></i>'
right = (f'<em><strong style="font-size:14px">{_fmt_price(btc["last"])}</strong>&nbsp;&nbsp;{_e(btc.get("chg_label") or "")}'
f'&nbsp;<span style="color:{col};font-weight:700">{tri}{abs(chg):.2f}%</span></em>')
if not candles:
return f'<section class="btc card"><h3>{head}</h3><div style="text-align:center;font-size:24px;padding:30px 0">--</div></section>'
LBL, TOP, BOT = 66, 6, 16
px0, px1 = 0, W - LBL
py0, py1 = TOP, H - BOT
hi_raw, lo_raw = max(c["h"] for c in candles), min(c["l"] for c in candles)
pad = (hi_raw - lo_raw or 1) * 0.04
lo, hi = lo_raw - pad, hi_raw + pad
Y = lambda v: py1 - (v - lo) / (hi - lo) * (py1 - py0)
n = len(candles)
slot = (px1 - px0) / n
bw = max(3, int(slot * 0.6)) | 1
parts = [f'<svg width="{W}" height="{H}" viewBox="0 0 {W} {H}" shape-rendering="crispEdges">',
f'<line x1="{px0}" y1="{py1}" x2="{px1}" y2="{py1}" stroke="#000" stroke-dasharray="2 3"/>']
for i, c in enumerate(candles):
cx = round(px0 + slot * (i + 0.5))
col = GREEN if c["c"] >= c["o"] else RED
top, bot = sorted((round(Y(c["o"])), round(Y(c["c"]))))
parts.append(f'<line x1="{cx}" y1="{round(Y(c["h"]))}" x2="{cx}" y2="{round(Y(c["l"]))}" stroke="{col}"/>')
parts.append(f'<rect x="{cx - bw // 2}" y="{top}" width="{bw}" height="{max(1, bot - top)}" fill="{col}"/>')
last = candles[-1]["c"]
ly = round(Y(last))
parts.append(f'<line x1="{px0}" y1="{ly}" x2="{px1}" y2="{ly}" stroke="#000" stroke-dasharray="1 3"/>')
hi_y, lo_y = py0 + 5, py1 - 1
ty = min(max(ly + 4, hi_y + 13), lo_y - 13)
txt = 'font-size="12" font-family="SF Mono,Menlo,Consolas,monospace" text-anchor="end"'
parts.append(f'<text x="{W}" y="{hi_y}" {txt}>{_fmt_price(hi_raw)}</text>')
parts.append(f'<text x="{W}" y="{lo_y}" {txt}>{_fmt_price(lo_raw)}</text>')
parts.append(f'<text x="{W}" y="{ty}" {txt} font-weight="700">{_fmt_price(last)}</text>')
lab = lambda c: _e(c.get("t") or c.get("d") or "")
ax = 'font-size="11" font-family="SF Mono,Menlo,Consolas,monospace"'
parts.append(f'<text x="{px0}" y="{H - 3}" {ax}>{lab(candles[0])}</text>')
parts.append(f'<text x="{(px0 + px1) / 2}" y="{H - 3}" {ax} text-anchor="middle">{lab(candles[n // 2])}</text>')
parts.append(f'<text x="{px1}" y="{H - 3}" {ax} text-anchor="end">{lab(candles[-1])}</text>')
parts.append("</svg>")
return f'<section class="btc card"><h3>{head}{right}</h3>{"".join(parts)}</section>'
def render_html(d):
"""data dict同 renderer_e1002.render 的输入)→ 完整 HTML 字符串。"""
dt, wt = d["date"], d["weather"]
head = (
f'<header class="hd"><div><div class="clock">{_e(dt["time"])}</div>'
f'<div class="date">{_e(dt["week"])}&nbsp;&nbsp;{_e(dt["greg"])}</div></div>'
f'<div class="wx"><div class="c"><div class="loc"><span class="pin"></span>{_e(wt["loc"])}</div>'
f'{_e(wt["cond"])}<br>{_e(wt["range"])}</div><div class="t">{_e(wt["temp"])}</div></div></header>')
body = (head
+ f'<div class="row">{_usage_card(d["usage"])}{_usage_card(d.get("usage_gpt") or {"title": "Codex Usage"})}</div>'
+ _realtime(d["mao"])
+ _btc(d.get("btc")))
return ("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=800, initial-scale=1\">"
"<meta http-equiv=\"Cache-Control\" content=\"no-store\">"
"<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>"
"<link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700;800&display=swap\" rel=\"stylesheet\">"
f"<title>eink dashboard</title><style>{_CSS}</style></head><body><div class=\"page\">{body}</div></body></html>")