- server.py:三条恒定 URL 并行——/e1002.png 内存现渲染 PNG(主力)、 /e1002.html 现代网页、/e1002-web.png 网页经无头 Chrome 截图后固化六色; 天气/橘喵/BTC 按 WEATHER_TTL/MAO_TTL/BTC_TTL 走内存缓存,过期才实拉、 失败沿用旧值;用量不读本机文件,改由 POST /push/usage(_gpt) 推送暂存 - push_client.py:本机把 Claude 用量快照推送到服务(X-Push-Token 鉴权) - btc_api.py:OKX/Coinbase/Huobi 免费接口逐源兜底,时K 近 72 小时,涨绿跌红 - data.py 拆出 weather_fetch/mao_fetch/btc_fetch 与 usage_from_raw, 统一按 TZ_NAME 时区;宽版只用点阵字体整数倍字号 12/24/36/48 - probe_server.py:验证平台对同一 URL 是否周期重抓(结论:Image 控件 不重抓,HTML 控件重抓) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
188 lines
11 KiB
Python
188 lines
11 KiB
Python
#!/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、右 ChatGPT Usage;橘喵今日实时三列;BTC 时 K(SVG)。
|
||
- 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑;pace ≥+tol 红 / ≤−tol 绿 / 其间黑;环比同比 涨红 跌黑;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
|
||
if pct >= usage.get("warn", 80):
|
||
return RED, RED
|
||
if pct <= usage.get("ok", 30):
|
||
return GREEN, BLACK
|
||
return BLACK, BLACK
|
||
|
||
|
||
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 = RED if direction == "up" 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> {_e(btc.get("chg_label") or "")}'
|
||
f' <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"])} {_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": "ChatGPT 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>")
|