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>
This commit is contained in:
2026-09-14 14:08:21 +08:00
parent 2015947812
commit 7378d6bb88
15 changed files with 561 additions and 108 deletions

View File

@@ -1,22 +1,24 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
推送端(本机运行)—— 本机的用量原始快照 POST 到 server.py服务端暂存、渲染时现算 pace。
推送端(本机运行)—— 本机采集两路账户级 AI 用量,POST 到 server.py服务端暂存、渲染时现算 pace。
推什么:
- Claude~/.claude/usage-snapshot.json 原文statusline 生产,含 five_hour / seven_day / seven_day_fable / updated_at)→ POST /push/usage
- ChatGPT暂无本机数据源将来有了按 data.GPT_WINDOWS 的键five_hour / seven_day / seven_day_codex各含 utilization + resets_at
组一个 dict → POST /push/usage_gpt。目前不推服务端用 mock 并标「示例数据」。
推什么(形态 A凭证与采集都留在本机服务端不持任何凭证、不读本机文件
- Claudeclaude_usage.get_usage()(复用 Claude Code 钥匙串登录态调 /api/oauth/usage)→ POST /push/usage
- Codexcodex_usage.get_usage()`codex app-server` RPC独立 CODEX_HOME 的 ChatGPT 登录)→ POST /push/usage_gpt
两路彼此独立:一路失败不影响另一路;两路都失败才以非零码退出。采集器自带 ≥300s 最小间隔缓存,
所以与 main.py小屏渲染在同一分钟内先后运行也只打一次接口。
鉴权:请求头 X-Push-Token = PUSH_TOKEN。网络直连不走代理连接类异常重试 2 次。
调度:与喵喵设备推送同节律,由 launchd 每 5 分钟跑一次 `main.py --target push`或加进 run.sh
调度:与喵喵设备推送同节律,由 launchd 每 5 分钟跑一次 `main.py --target push`run.sh 末尾)。
"""
import json
import time
import urllib.error
import urllib.request
import claude_usage
import codex_usage
import config
import usage_local
RETRY_TIMES, RETRY_DELAY, TIMEOUT = 2, 2, 10
@@ -28,10 +30,11 @@ def _post_json(url, payload):
"X-Push-Token": config.PUSH_TOKEN,
"User-Agent": "eink-push/push_client",
})
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
last = None
for i in range(RETRY_TIMES + 1):
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
with opener.open(req, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e: # 4xx/5xx 不重试,直接报出服务端的说明
raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}") from e
@@ -42,13 +45,29 @@ def _post_json(url, payload):
raise RuntimeError(f"连接失败:{last}")
SOURCES = (
("Claude", claude_usage.get_usage, "/push/usage"),
("Codex", codex_usage.get_usage, "/push/usage_gpt"),
)
def push_usage():
if not config.PUSH_URL or not config.PUSH_TOKEN:
raise SystemExit("[推送] 需在 .env 配置 PUSH_URL 与 PUSH_TOKEN")
raw = usage_local.get_usage() # 缺文件直接抛错——没有快照就没什么可推
resp = _post_json(config.PUSH_URL.rstrip("/") + "/push/usage", raw)
print(f"[推送] Claude 用量快照已推送到 {config.PUSH_URL}{resp}")
return resp
base = config.PUSH_URL.rstrip("/")
ok = 0
for name, getter, path in SOURCES:
try:
raw = getter()
status = raw.pop("status", "")
resp = _post_json(base + path, raw)
print(f"[推送] {name} 用量({status})已推送到 {base}{path}{resp}")
ok += 1
except Exception as e:
print(f"[推送] {name} 用量跳过:{e}")
if ok == 0:
raise SystemExit(1)
return ok
if __name__ == "__main__":