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>
This commit is contained in:
2026-09-10 18:55:47 +08:00
parent 6dc89b504e
commit 85b5113ba4
14 changed files with 1192 additions and 114 deletions

97
html_shot.py Normal file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
方案三:把 html_renderer 生成的网页用**服务端无头 Chrome** 截成 800x480再**固化为六色 PNG**(最近色、不抖动)。
为什么需要它SenseCraft 抓 HTML 时是平台自己截图再量化,文字抗锯齿的灰边会被它抖动成噪点、细笔画被打断(真机实拍已确认)。
在我们这一侧先截图、先量化,交给平台的就是纯六色图,平台无从抖动——效果对齐 PNG 方案,同时保留现代字体与网页排版。
量化策略(不抖动):
1. 每通道二值化v < HTML_PNG_THRESHOLD → 0否则 → 255。阈值默认 160偏向保墨迹抗锯齿的中灰边缘归黑
细笔画不会因为「不够黑」而消失;纯红/绿/黄/蓝/黑/白本身各通道就是 0/255不受影响。
2. 二值化后像素落在 {0,255}^3 的 8 个角,其中青/品/其它非六色角极少见(只可能出现在彩色抗锯齿边缘),
再用 Pillow 的调色板量化dither=NONE映射到最近的六色。
→ 输出保证只含 SPECTRA6 六色。
无头 Chrome 只能把截图写到文件这里用临时文件并立刻删除Linux 下 /tmp 通常是 tmpfs不落磁盘
若想彻底零文件需改用 Playwright/CDP本项目不引第三方依赖先不做。
"""
import os
import shutil
import subprocess
import tempfile
import threading
from PIL import Image
import config
from renderer_e1002 import SPECTRA6
_CANDIDATES = (
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome",
)
def find_chrome():
if config.CHROME_BIN:
return config.CHROME_BIN
for c in _CANDIDATES:
if os.path.isabs(c):
if os.path.exists(c):
return c
elif shutil.which(c):
return shutil.which(c)
raise RuntimeError("未找到 Chrome/Chromium请配置 CHROME_BIN")
_SHOT_LOCK = threading.Lock() # 无头 Chrome 共用默认 profile截图串行化避免互相干扰
def screenshot_html(html: str, size=(800, 480), timeout=40) -> Image.Image:
"""HTML 字符串 → 无头 Chrome 截图RGB Image。临时文件用后即删。
注意不要传 --user-data-dir 指向全新目录:新版 --headless=new 对空 profile 的首启流程会卡死到超时(实测)。"""
chrome = find_chrome()
tmpdir = tempfile.mkdtemp(prefix="eink-shot-")
html_path = os.path.join(tmpdir, "page.html")
png_path = os.path.join(tmpdir, "shot.png")
try:
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
cmd = [chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1",
f"--window-size={size[0]},{size[1]}", "--virtual-time-budget=6000", f"--screenshot={png_path}"]
if config.CHROME_EXTRA_ARGS:
cmd += config.CHROME_EXTRA_ARGS.split()
cmd.append("file://" + html_path)
with _SHOT_LOCK:
subprocess.run(cmd, check=True, timeout=timeout, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
with Image.open(png_path) as im:
return im.convert("RGB").copy()
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def quantize6(img: Image.Image, threshold=None) -> Image.Image:
"""RGB → 六色纯色(最近色、不抖动)。见模块文档。"""
t = config.HTML_PNG_THRESHOLD if threshold is None else threshold
lut = [0 if v < t else 255 for v in range(256)] * 3
bin_img = img.convert("RGB").point(lut)
pal = Image.new("P", (1, 1))
flat = [c for rgb in SPECTRA6 for c in rgb]
pal.putpalette(flat + [0] * (768 - len(flat)))
q = bin_img.quantize(palette=pal, dither=Image.Dither.NONE)
return q.convert("RGB")
def html_to_png6(html: str) -> Image.Image:
return quantize6(screenshot_html(html))
if __name__ == "__main__":
import sys, data, html_renderer
d = data.get_dashboard_data()
im = html_to_png6(html_renderer.render_html(d))
out = sys.argv[1] if len(sys.argv) > 1 else "output/e1002/web6.png"
os.makedirs(os.path.dirname(out), exist_ok=True)
im.save(out)
print(out, im.size, sorted(c for _, c in im.getcolors(64)))