Files
eink-push/html_renderer.py
YANG JIANKUAN 85b5113ba4 feat: E1002 新增公网图片服务(恒定 URL 现渲染)+底部改为 BTC 时K
- 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>
2026-09-10 18:55:47 +08:00

188 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、右 ChatGPT Usage橘喵今日实时三列BTC 时 KSVG
- 配色规则与 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>&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": "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>")