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

80
btc_api.py Normal file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
比特币 K 线行情客户端 —— 免费、无需密钥的公开接口,按顺序尝试多个源,首个成功即返回。
源(均为公开 REST无鉴权
1. OKX GET /api/v5/market/candles?instId=BTC-USDT&bar=<1H|1D> —— 主源,本地直连可达、数据最新
2. Coinbase GET /products/BTC-USD/candles?granularity=<3600|86400> —— 备源USD 计价,与 USDT 数值几乎一致)
3. Huobi GET /market/history/kline?period=<60min|1day>&symbol=btcusdt —— 备源
Binance 在本地区返回 restricted location不列入。
周期用统一代号 bar"1H"(时 K/ "1D"(日 K由 BARS 映射到各源的写法。
统一输出:按时间**升序**的 list[dict],每根 {"ts": epoch 秒, "o", "h", "l", "c"}float最后一根为当前未收盘的那根。
任何源失败抛异常并尝试下一个;全部失败抛 RuntimeError由 data 层决定沿用缓存或占位。
"""
import json
import urllib.request
TIMEOUT = 8
UA = "eink-push/1.0 (+https://github.com/)"
# 周期代号 → 各源参数:(OKX bar, Coinbase granularity 秒, Huobi period)
BARS = {"1H": ("1H", 3600, "60min"), "1D": ("1D", 86400, "1day")}
def _get_json(url):
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
def _okx(bar, limit):
j = _get_json(f"https://www.okx.com/api/v5/market/candles?instId=BTC-USDT&bar={BARS[bar][0]}&limit={limit}")
if str(j.get("code")) != "0":
raise RuntimeError(f"okx code={j.get('code')} msg={j.get('msg')}")
rows = j["data"] # 最新在前:[ts_ms, o, h, l, c, vol, ...]
out = [{"ts": int(r[0]) // 1000, "o": float(r[1]), "h": float(r[2]), "l": float(r[3]), "c": float(r[4])} for r in rows]
return sorted(out, key=lambda x: x["ts"])
def _coinbase(bar, limit):
rows = _get_json(f"https://api.exchange.coinbase.com/products/BTC-USD/candles?granularity={BARS[bar][1]}")
# 最新在前:[time, low, high, open, close, volume]
out = [{"ts": int(r[0]), "o": float(r[3]), "h": float(r[2]), "l": float(r[1]), "c": float(r[4])} for r in rows]
return sorted(out, key=lambda x: x["ts"])[-limit:]
def _huobi(bar, limit):
j = _get_json(f"https://api.huobi.pro/market/history/kline?period={BARS[bar][2]}&size={limit}&symbol=btcusdt")
if j.get("status") != "ok":
raise RuntimeError(f"huobi status={j.get('status')} err={j.get('err-msg')}")
out = [{"ts": int(r["id"]), "o": float(r["open"]), "h": float(r["high"]), "l": float(r["low"]), "c": float(r["close"])}
for r in j["data"]]
return sorted(out, key=lambda x: x["ts"])
SOURCES = (("okx", _okx), ("coinbase", _coinbase), ("huobi", _huobi))
def get_candles(bar="1H", limit=36):
"""返回 (源名, 升序 K 线列表)。bar 为 "1H"/"1D"。逐源尝试,全部失败抛 RuntimeError。"""
if bar not in BARS:
raise ValueError(f"不支持的周期 {bar!r},可选:{list(BARS)}")
errors = []
for name, fn in SOURCES:
try:
rows = fn(bar, limit)
if len(rows) >= 2:
return name, rows[-limit:]
errors.append(f"{name}: 数据不足({len(rows)})")
except Exception as e: # noqa: BLE001 —— 逐源兜底,任何异常都换下一个源
errors.append(f"{name}: {e}")
raise RuntimeError(f"BTC {bar} K 线全部源失败:" + "".join(errors))
if __name__ == "__main__":
src, rows = get_candles("1H", 5)
print(src)
for r in rows:
print(r)