feat: server.py 迁移部署到 NGCQ(1Panel Nginx 反代 eink.njcqit.com)+境内可达 BTC 行情源+页面访问令牌
- btc_api:新增 gateio/huobi_aws/binance_vision 三源,config 增 BTC_SOURCES 控制尝试顺序(境内 okx/coinbase/huobi 主域名不通) - server:IMAGE_TOKEN 非空时三个渲染端点须带 ?token=…,否则与未知路径同样 404(防爬虫扫描);推送令牌改常量时间比较 - 文档:CLAUDE.md/README/.env.example 记录 NGCQ 部署(systemd eink-server、1Panel API 建站坑)、本机 server/cloudflared 已卸载 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,9 @@ CHROME_BIN=
|
|||||||
CHROME_EXTRA_ARGS=
|
CHROME_EXTRA_ARGS=
|
||||||
HTML_PNG_THRESHOLD=160
|
HTML_PNG_THRESHOLD=160
|
||||||
PUSH_TOKEN=
|
PUSH_TOKEN=
|
||||||
|
# 页面访问令牌:非空时 /e1002.png、/e1002.html、/e1002-web.png 必须带 ?token=<IMAGE_TOKEN>,否则 404(防爬虫扫描);
|
||||||
|
# SenseCraft HTML 控件填 https://<域名>/e1002.png?token=<IMAGE_TOKEN>。生成:python3 -c "import secrets; print(secrets.token_urlsafe(24))"
|
||||||
|
IMAGE_TOKEN=
|
||||||
PUSH_STATE_PATH=
|
PUSH_STATE_PATH=
|
||||||
# 推送端(本机):服务地址(协议+域名/端口,不含路径),令牌与服务端一致
|
# 推送端(本机):服务地址(协议+域名/端口,不含路径),令牌与服务端一致
|
||||||
PUSH_URL=
|
PUSH_URL=
|
||||||
@@ -47,6 +50,9 @@ PACE_TOL=10
|
|||||||
BTC_BAR=1H
|
BTC_BAR=1H
|
||||||
BTC_LIMIT=48
|
BTC_LIMIT=48
|
||||||
BTC_TTL=300
|
BTC_TTL=300
|
||||||
|
# 行情源尝试顺序(可选:okx,coinbase,huobi,gateio,huobi_aws,binance_vision;未列出的按默认顺序追加)。
|
||||||
|
# 境内服务器 okx/coinbase/huobi 主域名不通且各超时数秒,部署 NGCQ 时设为 gateio,huobi_aws,binance_vision
|
||||||
|
BTC_SOURCES=okx,coinbase,huobi,gateio,huobi_aws,binance_vision
|
||||||
# ---- AI 用量(本机采集 → main.py --target push 上报;server.py 不持凭证)----
|
# ---- AI 用量(本机采集 → main.py --target push 上报;server.py 不持凭证)----
|
||||||
# Claude:复用 Claude Code 钥匙串登录态调 /api/oauth/usage(只读不刷新)。快照有效期(秒,同 WEATHER_TTL 语义,0=每次实拉)建议 ≥300,失败退避 1800 秒
|
# Claude:复用 Claude Code 钥匙串登录态调 /api/oauth/usage(只读不刷新)。快照有效期(秒,同 WEATHER_TTL 语义,0=每次实拉)建议 ≥300,失败退避 1800 秒
|
||||||
CLAUDE_USAGE_TTL=300
|
CLAUDE_USAGE_TTL=300
|
||||||
|
|||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -34,7 +34,7 @@ EPD_HOST=epd400a44.local python3 main.py
|
|||||||
|
|
||||||
无测试、无 lint。调试渲染效果就跑 `--render-only` 看 `output/dashboard.png`。
|
无测试、无 lint。调试渲染效果就跑 `--render-only` 看 `output/dashboard.png`。
|
||||||
|
|
||||||
**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()` 把 `.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量(cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证(fallback 为空串)。可配置项:`EPD_HOST`(局域网设备 mDNS host,默认 `epd400a44.local`)、`JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID`、`QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`(默认南京·江宁)/`WEATHER_TTL`、设备遥测 `SENSECRAFT_API_KEY`/`SENSECRAFT_DEVICE_EUI`/`DEVICE_TTL`、服务端 `FETCH_BUDGET`、`USAGE_WARN`、Claude 采集 `CLAUDE_USAGE_TTL`(快照有效期,语义同 `WEATHER_TTL`,0=每次实拉)/`CLAUDE_USAGE_BACKOFF`/`CLAUDE_USAGE_SCOPE_MODEL`、Codex 采集 `CODEX_BIN`/`CODEX_USAGE_HOME`/`CODEX_USAGE_TTL`/`CODEX_USAGE_BACKOFF`、仅宽版的 `USAGE_OK`/`PACE_TOL`/`BTC_BAR`/`BTC_LIMIT`/`BTC_TTL`/`MAO_TTL`、`E1002_COLOR_STRIP`。
|
**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()` 把 `.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量(cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证(fallback 为空串)。可配置项:`EPD_HOST`(局域网设备 mDNS host,默认 `epd400a44.local`)、`JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID`、`QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`(默认南京·江宁)/`WEATHER_TTL`、设备遥测 `SENSECRAFT_API_KEY`/`SENSECRAFT_DEVICE_EUI`/`DEVICE_TTL`、服务端 `FETCH_BUDGET`/`IMAGE_TOKEN`(页面访问令牌)/`PUSH_TOKEN`、`USAGE_WARN`、Claude 采集 `CLAUDE_USAGE_TTL`(快照有效期,语义同 `WEATHER_TTL`,0=每次实拉)/`CLAUDE_USAGE_BACKOFF`/`CLAUDE_USAGE_SCOPE_MODEL`、Codex 采集 `CODEX_BIN`/`CODEX_USAGE_HOME`/`CODEX_USAGE_TTL`/`CODEX_USAGE_BACKOFF`、仅宽版的 `USAGE_OK`/`PACE_TOL`/`BTC_BAR`/`BTC_LIMIT`/`BTC_TTL`/`BTC_SOURCES`(行情源顺序,境内服务器须排 gateio/huobi_aws/binance_vision 在前)/`MAO_TTL`、`E1002_COLOR_STRIP`。
|
||||||
|
|
||||||
**定时部署**:由 macOS **launchd LaunchAgent** 触发 `run.sh`(不再用 cron——cron 在现代 macOS 上易被 TCC/权限静默拦截,launchd 是系统原生调度器)。LaunchAgent plist 装在 `~/Library/LaunchAgents/com.jm.eink-push.plist`(机器专用,未入库,等价于 `run.sh`/`.env` 的本机产物),`StartInterval=300` + `RunAtLoad=true`:登录/重启后立即跑一次,此后每 5 分钟一次。用 LaunchAgent(非 LaunchDaemon)是因为脚本依赖当前用户的登录钥匙串(Claude Code 凭证)、`~/.codex`(Codex 登录态)、`.env`,必须在用户会话下运行——机器需已登录(或开了自动登录)才能保证开机即触发。`run.sh` 内部逻辑不变:cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log`(`cd` 后 `config.py` 才能就近找到 `.env`);launchd 自身的 stdout/stderr 另落 `output/launchd.log`(正常应为空,非空说明进程本身起不来,而非业务报错)。常用排查:`launchctl list | grep com.jm.eink-push` 看是否常驻及最近退出码,`launchctl kickstart -k gui/$(id -u)/com.jm.eink-push` 手动立即触发一次;改 plist 后需 `launchctl bootout` + `bootstrap` 重新加载才生效(热改文件不会自动生效)。每 5 分钟触发:业务数据每次都刷新,天气靠缓存、两路用量各有 `CLAUDE_USAGE_TTL`/`CODEX_USAGE_TTL` 有效期缓存(默认 300s,同一分钟内小屏渲染与上报两个进程只打一次接口),避免高频打接口(见 `data.py`)。
|
**定时部署**:由 macOS **launchd LaunchAgent** 触发 `run.sh`(不再用 cron——cron 在现代 macOS 上易被 TCC/权限静默拦截,launchd 是系统原生调度器)。LaunchAgent plist 装在 `~/Library/LaunchAgents/com.jm.eink-push.plist`(机器专用,未入库,等价于 `run.sh`/`.env` 的本机产物),`StartInterval=300` + `RunAtLoad=true`:登录/重启后立即跑一次,此后每 5 分钟一次。用 LaunchAgent(非 LaunchDaemon)是因为脚本依赖当前用户的登录钥匙串(Claude Code 凭证)、`~/.codex`(Codex 登录态)、`.env`,必须在用户会话下运行——机器需已登录(或开了自动登录)才能保证开机即触发。`run.sh` 内部逻辑不变:cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log`(`cd` 后 `config.py` 才能就近找到 `.env`);launchd 自身的 stdout/stderr 另落 `output/launchd.log`(正常应为空,非空说明进程本身起不来,而非业务报错)。常用排查:`launchctl list | grep com.jm.eink-push` 看是否常驻及最近退出码,`launchctl kickstart -k gui/$(id -u)/com.jm.eink-push` 手动立即触发一次;改 plist 后需 `launchctl bootout` + `bootstrap` 重新加载才生效(热改文件不会自动生效)。每 5 分钟触发:业务数据每次都刷新,天气靠缓存、两路用量各有 `CLAUDE_USAGE_TTL`/`CODEX_USAGE_TTL` 有效期缓存(默认 300s,同一分钟内小屏渲染与上报两个进程只打一次接口),避免高频打接口(见 `data.py`)。
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ EPD_HOST=epd400a44.local python3 main.py
|
|||||||
- **`jm_api.py`** — jm-devops 后端统计接口客户端,**设备密钥授权**(`grant_type=client_secret`,免验证码)。先用 `CLIENT_ID + CLIENT_SECRET` 调 `POST /auth/login` 换 token(进程内缓存、按 `expire_in` 提前刷新),再带 `Authorization: Bearer <token>` + `clientid` 头调 `/system/statistics/{realtime,recent-days}`;遇 401 清 token 重登一次重试。token 字段为蛇形 `access_token`/`expire_in`。注:历史上曾免鉴权直调、更早曾用账号密码+验证码 OCR 登录(ddddocr 不稳定),现统一为 client_secret 授权——密钥即长期凭证,务必走 HTTPS、优先用 `JM_CLIENT_SECRET` 环境变量、可在后端 `sys_client` 轮换。
|
- **`jm_api.py`** — jm-devops 后端统计接口客户端,**设备密钥授权**(`grant_type=client_secret`,免验证码)。先用 `CLIENT_ID + CLIENT_SECRET` 调 `POST /auth/login` 换 token(进程内缓存、按 `expire_in` 提前刷新),再带 `Authorization: Bearer <token>` + `clientid` 头调 `/system/statistics/{realtime,recent-days}`;遇 401 清 token 重登一次重试。token 字段为蛇形 `access_token`/`expire_in`。注:历史上曾免鉴权直调、更早曾用账号密码+验证码 OCR 登录(ddddocr 不稳定),现统一为 client_secret 授权——密钥即长期凭证,务必走 HTTPS、优先用 `JM_CLIENT_SECRET` 环境变量、可在后端 `sys_client` 轮换。
|
||||||
- **`weather_api.py`** — 和风天气(QWeather)客户端。用**用户专属 API Host**(`QWEATHER_HOST`)+ `X-QW-Api-Key` 头鉴权;响应 **gzip 压缩**(按 magic number 手动解压)、返回 `code` 为**字符串**。`get_now()` 取实时(`now.text` 中文天气 / `now.temp` / `now.icon` 图标代码),`get_today()` 取 `/3d` 的 `daily[0]` 今日温区。`QWEATHER_HOST`/`QWEATHER_KEY` 未配置时直接抛错→天气区显示 `"--"`。
|
- **`weather_api.py`** — 和风天气(QWeather)客户端。用**用户专属 API Host**(`QWEATHER_HOST`)+ `X-QW-Api-Key` 头鉴权;响应 **gzip 压缩**(按 magic number 手动解压)、返回 `code` 为**字符串**。`get_now()` 取实时(`now.text` 中文天气 / `now.temp` / `now.icon` 图标代码),`get_today()` 取 `/3d` 的 `daily[0]` 今日温区。`QWEATHER_HOST`/`QWEATHER_KEY` 未配置时直接抛错→天气区显示 `"--"`。
|
||||||
- **`sensecraft_api.py`** — SenseCraft HMI 设备遥测客户端:`GET {SENSECRAFT_API_BASE}/api/v1/user/device/iot_data/{SENSECRAFT_DEVICE_EUI}`,头 `api-key`(用户在 SenseCraft 后台生成,放 `.env`,**2026-09-14 该 key 曾出现在对话里,建议轮换**),取 `result.battery.level/charging`、`result.sensor.temp/humidity`;设备每 `dataaccess.interval`=300s 上报一次,故 `DEVICE_TTL` 默认 300。直连不走代理、超时 10s、`code!=200` 抛错 → 状态栏 `--`。
|
- **`sensecraft_api.py`** — SenseCraft HMI 设备遥测客户端:`GET {SENSECRAFT_API_BASE}/api/v1/user/device/iot_data/{SENSECRAFT_DEVICE_EUI}`,头 `api-key`(用户在 SenseCraft 后台生成,放 `.env`,**2026-09-14 该 key 曾出现在对话里,建议轮换**),取 `result.battery.level/charging`、`result.sensor.temp/humidity`;设备每 `dataaccess.interval`=300s 上报一次,故 `DEVICE_TTL` 默认 300。直连不走代理、超时 10s、`code!=200` 抛错 → 状态栏 `--`。
|
||||||
- **`btc_api.py`** — 比特币 K 线客户端,免费公开接口、无需密钥,`get_candles(bar, limit)`,周期代号 `BARS = {"1H", "1D"}` 映射到各源写法,逐源兜底 `SOURCES = (okx, coinbase, huobi)`:OKX `/api/v5/market/candles?instId=BTC-USDT&bar=`(主源,本地直连可达且最新)、Coinbase `/products/BTC-USD/candles?granularity=`、Huobi `/market/history/kline?period=`;各源字段顺序不同(OKX/Huobi 最新在前,Coinbase 为 [time, low, high, open, close]),统一归一为升序 `{ts, o, h, l, c}`。Binance 在本地区返回 restricted location,不列入。
|
- **`btc_api.py`** — 比特币 K 线客户端,免费公开接口、无需密钥,`get_candles(bar, limit)`,周期代号 `BARS = {"1H", "1D"}` 映射到各源写法,逐源兜底 `SOURCES = (okx, coinbase, huobi, gateio, huobi_aws, binance_vision)`:OKX `/api/v5/market/candles?instId=BTC-USDT&bar=`(主源,本地直连可达且最新)、Coinbase `/products/BTC-USD/candles?granularity=`、Huobi `/market/history/kline?period=`(`huobi_aws` 同接口换 `api-aws.huobi.pro`)、Gate.io `/api/v4/spot/candlesticks`(升序 [ts, 成交额, close, high, low, open, …])、Binance 公开镜像 `data-api.binance.vision/api/v3/klines`(升序 [openTime ms, o, h, l, c, …]);各源字段顺序不同(OKX/Huobi 最新在前,Coinbase 为 [time, low, high, open, close]),统一归一为升序 `{ts, o, h, l, c}`。尝试顺序由 `config.BTC_SOURCES`(`_ordered_sources()`,未列出的按默认顺序追加)决定:本机默认 okx 在前;**境内 NGCQ 服务器 okx/coinbase/huobi 主域名不通**,`.env` 设 `gateio,huobi_aws,binance_vision`。`api.binance.com` 在本地区返回 restricted location,不列入。
|
||||||
- **`claude_usage.py`** — Claude **账户级**用量采集(形态 A,跑在本机):**复用 Claude Code 自身的 OAuth 登录态**,直接 GET `https://api.anthropic.com/api/oauth/usage`(头 `Authorization: Bearer <accessToken>` + `anthropic-beta: oauth-2025-04-20`)——与 Claude Code `/usage`、claude.ai 用量页同源,CLI/桌面 App/网页消耗都算,**不依赖 Claude Code 进程在跑、不依赖 statusline 或任何插件**(2026-09-14 起替代旧的「statusline 补丁落盘 `~/.claude/usage-snapshot.json`、本项目读文件」方案,`usage_local.py` 已删)。token 来源按序:环境变量 `CLAUDE_CODE_OAUTH_TOKEN` → macOS 钥匙串 generic password(service `Claude Code-credentials`,JSON `claudeAiOauth.accessToken/expiresAt(ms)`)→ `~/.claude/.credentials.json`(尊重 `CLAUDE_CONFIG_DIR`)。**铁律**:① **只读、绝不刷新 token**(refresh token 每次使用即轮换,第三方刷新不写回会让 Claude Code 强制重登,多个官方 issue 证实);access token 约 8 小时有效,过期抛 `TokenUnavailable` → 沿用旧快照,等 Claude Code 下次启动自动刷新;② **有效期 `CLAUDE_USAGE_TTL` 内不再调、建议 ≥300 秒**(突发调用 429 会封约 24 小时并连带 Claude Code `/usage`、claude.ai 一起看不到);③ 直连不走代理。`normalize()` 优先解析 `limits[]`(`kind`=session/weekly_all/weekly_scoped,模型限定周额度按 `scope.model.display_name == CLAUDE_USAGE_SCOPE_MODEL`「Fable」识别——2026 年中起顶层 `seven_day_opus/sonnet` 已置 null,只能这样取),顶层 `five_hour/seven_day` 兜底;输出与旧快照同构 `{five_hour, seven_day, seven_day_fable|None, updated_at, source}`。`get_usage(force=)` 经 `usage_common.cached_snapshot` 带缓存/最小间隔/退避,`status` ∈ fetched/cache/stale/backoff。官方合规页禁止第三方「收集、存储、中介」claude.ai 凭证,未专门点名此端点,个人自用只读监控属灰色地带、无封号报告——所以凭证只留本机、服务端不碰。
|
- **`claude_usage.py`** — Claude **账户级**用量采集(形态 A,跑在本机):**复用 Claude Code 自身的 OAuth 登录态**,直接 GET `https://api.anthropic.com/api/oauth/usage`(头 `Authorization: Bearer <accessToken>` + `anthropic-beta: oauth-2025-04-20`)——与 Claude Code `/usage`、claude.ai 用量页同源,CLI/桌面 App/网页消耗都算,**不依赖 Claude Code 进程在跑、不依赖 statusline 或任何插件**(2026-09-14 起替代旧的「statusline 补丁落盘 `~/.claude/usage-snapshot.json`、本项目读文件」方案,`usage_local.py` 已删)。token 来源按序:环境变量 `CLAUDE_CODE_OAUTH_TOKEN` → macOS 钥匙串 generic password(service `Claude Code-credentials`,JSON `claudeAiOauth.accessToken/expiresAt(ms)`)→ `~/.claude/.credentials.json`(尊重 `CLAUDE_CONFIG_DIR`)。**铁律**:① **只读、绝不刷新 token**(refresh token 每次使用即轮换,第三方刷新不写回会让 Claude Code 强制重登,多个官方 issue 证实);access token 约 8 小时有效,过期抛 `TokenUnavailable` → 沿用旧快照,等 Claude Code 下次启动自动刷新;② **有效期 `CLAUDE_USAGE_TTL` 内不再调、建议 ≥300 秒**(突发调用 429 会封约 24 小时并连带 Claude Code `/usage`、claude.ai 一起看不到);③ 直连不走代理。`normalize()` 优先解析 `limits[]`(`kind`=session/weekly_all/weekly_scoped,模型限定周额度按 `scope.model.display_name == CLAUDE_USAGE_SCOPE_MODEL`「Fable」识别——2026 年中起顶层 `seven_day_opus/sonnet` 已置 null,只能这样取),顶层 `five_hour/seven_day` 兜底;输出与旧快照同构 `{five_hour, seven_day, seven_day_fable|None, updated_at, source}`。`get_usage(force=)` 经 `usage_common.cached_snapshot` 带缓存/最小间隔/退避,`status` ∈ fetched/cache/stale/backoff。官方合规页禁止第三方「收集、存储、中介」claude.ai 凭证,未专门点名此端点,个人自用只读监控属灰色地带、无封号报告——所以凭证只留本机、服务端不碰。
|
||||||
- **`codex_usage.py`** — Codex **账户级**用量采集(形态 A,跑在本机):经官方 **`codex app-server` stdio JSON-RPC** 读 ChatGPT 订阅的 **Codex 额度池**(CLI/IDE 扩展/Codex Web/ChatGPT 桌面 App 内 Codex 共用)。**调研结论(2026-09-14)**:ChatGPT 订阅的**普通聊天**额度没有任何可编程的账户级读取途径(私有端点在 Sentinel/Cloudflare 门后、且只在被限流时才有值;官方明确聊天与 Codex 分开计量),故右侧块如实命名「Codex Usage」。协议(本机 codex-cli 0.154 验证):`initialize` → 通知 `initialized` → `account/rateLimits/read`(params `{excludeResetCreditDetails:true}`),响应 `rateLimits.primary/secondary{usedPercent, windowDurationMins, resetsAt(epoch 秒)}` + `planType` + 多桶 `rateLimitsByLimitId`;未登录 ChatGPT 账号时 error「codex account authentication required to read rate limits」。**窗口归类按 `windowDurationMins`**(≤12h → five_hour,≥5 天 → seven_day),Pro 计划常只回一个窗口、另一个 null,不能按 primary/secondary 位置判断;快照各窗口带 `window` 真实秒数供 pace 用。为什么不直调 `chatgpt.com/backend-api/wham/usage`:token 刷新由 Codex CLI 代管(refresh token 轮换,外部自刷会踢掉 CLI 登录,官方 CI 文档也明示「不要自己调刷新接口」)。**登录**:2026-09-14 用户直接在默认 `~/.codex` 用 ChatGPT 账号 `codex login`(`auth_mode=chatgpt`,原 API Key 仍保留在 auth.json;`config.toml` 的第三方中转 base_url 只影响推理、不影响额度读取),故 `CODEX_USAGE_HOME` 默认留空跟随默认目录;若要隔离可设独立 `CODEX_HOME`(目录不存在会自动建)并在其下 `codex login`。**`CODEX_BIN` 由 `config._which_codex()` 解析为绝对路径**(launchd 的 PATH 不含 `/opt/homebrew/bin`,曾报「找不到 codex 可执行文件」进入退避;`.env` 已写死 `/opt/homebrew/bin/codex`)。子进程剥掉代理环境变量直连(chatgpt.com 本地直连可达)。**真实数据(Plus 计划)**:`rateLimits.primary` 只有周窗口(`windowDurationMins=10080`)、`secondary=null`,`rateLimitsByLimitId` 仅一个桶 `codex`——故 5h 行为 `--`、7d 行有值;`buckets` 原样附带,若将来出现第二个桶再决定是否加第三行。
|
- **`codex_usage.py`** — Codex **账户级**用量采集(形态 A,跑在本机):经官方 **`codex app-server` stdio JSON-RPC** 读 ChatGPT 订阅的 **Codex 额度池**(CLI/IDE 扩展/Codex Web/ChatGPT 桌面 App 内 Codex 共用)。**调研结论(2026-09-14)**:ChatGPT 订阅的**普通聊天**额度没有任何可编程的账户级读取途径(私有端点在 Sentinel/Cloudflare 门后、且只在被限流时才有值;官方明确聊天与 Codex 分开计量),故右侧块如实命名「Codex Usage」。协议(本机 codex-cli 0.154 验证):`initialize` → 通知 `initialized` → `account/rateLimits/read`(params `{excludeResetCreditDetails:true}`),响应 `rateLimits.primary/secondary{usedPercent, windowDurationMins, resetsAt(epoch 秒)}` + `planType` + 多桶 `rateLimitsByLimitId`;未登录 ChatGPT 账号时 error「codex account authentication required to read rate limits」。**窗口归类按 `windowDurationMins`**(≤12h → five_hour,≥5 天 → seven_day),Pro 计划常只回一个窗口、另一个 null,不能按 primary/secondary 位置判断;快照各窗口带 `window` 真实秒数供 pace 用。为什么不直调 `chatgpt.com/backend-api/wham/usage`:token 刷新由 Codex CLI 代管(refresh token 轮换,外部自刷会踢掉 CLI 登录,官方 CI 文档也明示「不要自己调刷新接口」)。**登录**:2026-09-14 用户直接在默认 `~/.codex` 用 ChatGPT 账号 `codex login`(`auth_mode=chatgpt`,原 API Key 仍保留在 auth.json;`config.toml` 的第三方中转 base_url 只影响推理、不影响额度读取),故 `CODEX_USAGE_HOME` 默认留空跟随默认目录;若要隔离可设独立 `CODEX_HOME`(目录不存在会自动建)并在其下 `codex login`。**`CODEX_BIN` 由 `config._which_codex()` 解析为绝对路径**(launchd 的 PATH 不含 `/opt/homebrew/bin`,曾报「找不到 codex 可执行文件」进入退避;`.env` 已写死 `/opt/homebrew/bin/codex`)。子进程剥掉代理环境变量直连(chatgpt.com 本地直连可达)。**真实数据(Plus 计划)**:`rateLimits.primary` 只有周窗口(`windowDurationMins=10080`)、`secondary=null`,`rateLimitsByLimitId` 仅一个桶 `codex`——故 5h 行为 `--`、7d 行有值;`buckets` 原样附带,若将来出现第二个桶再决定是否加第三行。
|
||||||
- **`usage_common.py`** — 两个采集器共用的骨架 `cached_snapshot(path, ttl, backoff, fetch, force)`:距上次成功 <ttl 直接返回缓存(ttl 来自 `.env`,与 `WEATHER_TTL`/`MAO_TTL`/`BTC_TTL` 同语义,0=每次实拉)(launchd 每 5 分钟先后起「小屏渲染」「上报」两个进程,只打一次接口);失败沿用旧快照并退避 backoff 秒(期内不再尝试);从未成功且失败则抛出。缓存文件 `.claude_usage_cache.json`/`.codex_usage_cache.json` 只存快照与时间戳,**不存凭证**。
|
- **`usage_common.py`** — 两个采集器共用的骨架 `cached_snapshot(path, ttl, backoff, fetch, force)`:距上次成功 <ttl 直接返回缓存(ttl 来自 `.env`,与 `WEATHER_TTL`/`MAO_TTL`/`BTC_TTL` 同语义,0=每次实拉)(launchd 每 5 分钟先后起「小屏渲染」「上报」两个进程,只打一次接口);失败沿用旧快照并退避 backoff 秒(期内不再尝试);从未成功且失败则抛出。缓存文件 `.claude_usage_cache.json`/`.codex_usage_cache.json` 只存快照与时间戳,**不存凭证**。
|
||||||
@@ -61,7 +61,7 @@ EPD_HOST=epd400a44.local python3 main.py
|
|||||||
- **天气图标全矢量手绘**(无图片资源):`_weather_category()` 把和风 icon 代码归并为 8 类(sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog,识别不了回退 cloudy),再由 `_weather_icon()` 用 `_sun`/`_cloud` 等基元按 1bit 描边画出;改天气展示从这里动。
|
- **天气图标全矢量手绘**(无图片资源):`_weather_category()` 把和风 icon 代码归并为 8 类(sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog,识别不了回退 cloudy),再由 `_weather_icon()` 用 `_sun`/`_cloud` 等基元按 1bit 描边画出;改天气展示从这里动。
|
||||||
- **`pusher.py`** — 直连局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao,`web/` 即设备自带管理页,可浏览器打开 `http://<EPD_HOST>/` 调试同一套接口)。`push_image()` 先调 `GET /images` 拿画廊已存图片名单,逐个 `POST /delete_image?name=`清空(单张删除失败不阻塞后续),再 `POST /upload`(`Content-Type: image/png`,body 为原始 PNG 字节,非 multipart)触发设备转换+刷屏。**清空画廊是刻意设计**:设备把每次 `/upload` 都追加存一份到画廊(`GET /images` 可见),不清理会越推越多、且设备当前 `current_mode` 为 slideshow 会轮播到旧图;先清后传使画廊恒为 1 张,等价于始终静态展示最新一张。**清理非核心**:拉取画廊列表本身失败也不阻塞后续 `/upload`(跳过清理,直接推)。`_request()` 对连接类异常(`urllib.error.URLError`,含 mDNS `.local` 域名解析瞬时失败)内置重试(`RETRY_TIMES=2`,间隔 `RETRY_DELAY=2s`)——mDNS 基于组播 UDP,偶发丢包很正常,单次失败不该让整轮推送直接跳过。注:历史上曾走 Zectrix 公网 API(`POST /devices/{id}/display/image` + 设备 MAC + `ZECTRIX_API_KEY`),现统一为局域网直连。
|
- **`pusher.py`** — 直连局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao,`web/` 即设备自带管理页,可浏览器打开 `http://<EPD_HOST>/` 调试同一套接口)。`push_image()` 先调 `GET /images` 拿画廊已存图片名单,逐个 `POST /delete_image?name=`清空(单张删除失败不阻塞后续),再 `POST /upload`(`Content-Type: image/png`,body 为原始 PNG 字节,非 multipart)触发设备转换+刷屏。**清空画廊是刻意设计**:设备把每次 `/upload` 都追加存一份到画廊(`GET /images` 可见),不清理会越推越多、且设备当前 `current_mode` 为 slideshow 会轮播到旧图;先清后传使画廊恒为 1 张,等价于始终静态展示最新一张。**清理非核心**:拉取画廊列表本身失败也不阻塞后续 `/upload`(跳过清理,直接推)。`_request()` 对连接类异常(`urllib.error.URLError`,含 mDNS `.local` 域名解析瞬时失败)内置重试(`RETRY_TIMES=2`,间隔 `RETRY_DELAY=2s`)——mDNS 基于组播 UDP,偶发丢包很正常,单次失败不该让整轮推送直接跳过。注:历史上曾走 Zectrix 公网 API(`POST /devices/{id}/display/image` + 设备 MAC + `ZECTRIX_API_KEY`),现统一为局域网直连。
|
||||||
- **`renderer_e1002.py`** — reTerminal E1002 目标:`E1002Renderer(DashboardRenderer)` 以 **k=1** 复用组件(字号 12/24/48 与三色版一致),只重写版式与配色钩子(基类抽出类属性 `PCT_TRACK`(小屏 F16 百分比的手动字距),宽版置 0——其 F16 映射到 12px、加粗后由 `_track()` 自动加 1px 字距;基类另抽 `FPACE` 让 pace 字体与标签 F13 解耦)。四段、三道横规,各段 y 由上一段实际底部推算:A 顶部 0–90 **大时钟 F84 粗 3**(按 `getbbox` 把墨迹顶沿放到 16、底沿 79,占满整段且保住原上/左/下边距),其右 14px 起一列三行左缘对齐:电池行、温度湿度行(`_status_battery`/`_status_env`)、星期/日期 F12(按日期数字底沿与时钟**基线对齐**;三行中心约 30.5/52.5/74.5,行距 22);天气右(上层 图标 ×1.25 | 温度 F48 顶沿齐时钟+「°」F24 上标;下层 一行 定位+城市·天气·温区 右对齐,均贴 x=790,见分支说明);B 用量 `_usage_block`×2(左 `usage` 10–386|竖规 x=400|右 `usage_gpt` 414–790):`_label` 标题 + 右侧「上次更新 HH:MM」(与今日实时同款文案)或 mock 的「示例数据」,行用基类紧凑 `_usage_row`(行距 31,宽版把 `bar["label"]` 长标签代入 `k`,`label_w` 按本块最长标签自适应);C `_realtime` 今日实时五列全宽(`mao.cols` 全部,含 `wide` 列):列内四行 `ctext`/`_cmp_line` 逐行水平居中——名 F12 y0+21 / 值 `_value` F24 粗+小号 ¥/万 y0+38 / 环比 y0+72 / 同比 y0+88(按墨迹框:标题↔名 ~9px——2026-09-14 整列下移 4px,原 ~5px 用户觉得挤;名↔值 ~9px、值↔环比 ~6px),竖规 y0+26~102,区块底 y0+104;D `_btc_chart` BTC K 线(`data.btc`,标题按 `bar` 显示「时K/日K」:右侧最新价 F12 粗 + 口径 `chg_label`(24h/1d)+ 涨跌三角 + 涨跌幅(F12 粗,2026-09-14 加粗),涨绿跌红;每根蜡烛 1px 影线 + 实心实体(宽≈槽位 60% 取奇数)、收≥开 绿 否则 红;右侧 66px 标签列放最高/最低/最新价,最新价横贯一道点规、标签避让顶底;底部基线 + 首/中/末时间标签 `t`;无数据居中 `--`)。用量块 `_usage_block` 返回末条进度条底部,B/C 分割线取其 +16px(用户反馈太挤)。配色钩子覆写:`_usage_colors` 三档(≥`USAGE_WARN` 红|≤`USAGE_OK` 绿|其间黑;填充、边框、百分比文字三者同色)、`_pace_color` 三档(≥+`PACE_TOL` 红|≤−`PACE_TOL` 绿|其间黑),阈值由 `usage.ok`/`usage.pace_tol`(来自 config)在 `_usage_block` 内注入。天气图标 `wet=BLUE` 让雨滴/雪花着蓝。可选六色测试色条 `_color_strip`(`E1002_COLOR_STRIP=1` 打开,默认关)。`write_page()` 写出固定 800×480 的 `index.html`(零边距、`image-rendering: pixelated`、PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**:云端截图视口/DPR 不可控,自适应会重排+抗锯齿产生中间色再被平台抖动;固定页里的 1:1 PNG 每个像素本就是六色之一,截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致。设备端 HTML 控件须铺满整个画布。历史(均已弃用):k=2 整体放大版;「左用量/右今日实时/下 30 天折线」版;「用量大卡片撑满」版。
|
- **`renderer_e1002.py`** — reTerminal E1002 目标:`E1002Renderer(DashboardRenderer)` 以 **k=1** 复用组件(字号 12/24/48 与三色版一致),只重写版式与配色钩子(基类抽出类属性 `PCT_TRACK`(小屏 F16 百分比的手动字距),宽版置 0——其 F16 映射到 12px、加粗后由 `_track()` 自动加 1px 字距;基类另抽 `FPACE` 让 pace 字体与标签 F13 解耦)。四段、三道横规,各段 y 由上一段实际底部推算:A 顶部 0–90 **大时钟 F84 粗 3**(按 `getbbox` 把墨迹顶沿放到 16、底沿 79,占满整段且保住原上/左/下边距),其右 14px 起一列三行左缘对齐:电池行、温度湿度行(`_status_battery`/`_status_env`)、星期/日期 F12(按日期数字底沿与时钟**基线对齐**;三行中心约 30.5/52.5/74.5,行距 22);天气右(上层 图标 ×1.25 | 温度 F48 顶沿齐时钟+「°」F24 上标;下层 一行 定位+城市·天气·温区 右对齐,均贴 x=790,见分支说明);B 用量 `_usage_block`×2(左 `usage` 10–386|竖规 x=400|右 `usage_gpt` 414–790):`_label` 标题 + 右侧「上次更新 HH:MM」(与今日实时同款文案)或 mock 的「示例数据」,行用基类紧凑 `_usage_row`(行距 31,宽版把 `bar["label"]` 长标签代入 `k`,`label_w` 按本块最长标签自适应);C `_realtime` 今日实时五列全宽(`mao.cols` 全部,含 `wide` 列):列内四行 `ctext`/`_cmp_line` 逐行水平居中——名 F12 y0+21 / 值 `_value` F24 粗+小号 ¥/万 y0+38 / 环比 y0+72 / 同比 y0+88(按墨迹框:标题↔名 ~9px——2026-09-14 整列下移 4px,原 ~5px 用户觉得挤;名↔值 ~9px、值↔环比 ~6px),竖规 y0+26~102,区块底 y0+104;D `_btc_chart` BTC K 线(`data.btc`,标题按 `bar` 显示「时K/日K」:右侧最新价 F12 粗 + 口径 `chg_label`(24h/1d)+ 涨跌三角 + 涨跌幅(F12 粗,2026-09-14 加粗),涨绿跌红;每根蜡烛 1px 影线 + 实心实体(宽≈槽位 60% 取奇数)、收≥开 绿 否则 红;右侧 66px 标签列放最高/最低/最新价,最新价横贯一道点规、标签避让顶底;底部基线 + 首/中/末时间标签 `t`;无数据居中 `--`)。用量块 `_usage_block` 返回末条进度条底部,B/C 分割线取其 +16px(用户反馈太挤)。配色钩子覆写:`_usage_colors` 三档(≥`USAGE_WARN` 红|≤`USAGE_OK` 绿|其间黑;填充、边框、百分比文字三者同色)、`_pace_color` 三档(≥+`PACE_TOL` 红|≤−`PACE_TOL` 绿|其间黑),阈值由 `usage.ok`/`usage.pace_tol`(来自 config)在 `_usage_block` 内注入。天气图标 `wet=BLUE` 让雨滴/雪花着蓝。可选六色测试色条 `_color_strip`(`E1002_COLOR_STRIP=1` 打开,默认关)。`write_page()` 写出固定 800×480 的 `index.html`(零边距、`image-rendering: pixelated`、PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**:云端截图视口/DPR 不可控,自适应会重排+抗锯齿产生中间色再被平台抖动;固定页里的 1:1 PNG 每个像素本就是六色之一,截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致。设备端 HTML 控件须铺满整个画布。历史(均已弃用):k=2 整体放大版;「左用量/右今日实时/下 30 天折线」版;「用量大卡片撑满」版。
|
||||||
- **`server.py`** — E1002 动态服务(stdlib `ThreadingHTTPServer`,无第三方依赖)。`build_data()` → `(data, 各路状态)`:日期按 `config.TZ_NAME` 时区现算;四路上游(天气/橘喵/BTC/设备遥测)经 `_cached(key, ttl, fetch)`——`STATE.last_good[key]` 未过期(`time - STATE.fetched_at[key] < ttl`)直接用;过期的**并行**提交到线程池 `_POOL`(`_refresh` 跑 `data.weather_fetch()`/`data.mao_fetch(now, with_trend=False)`/`data.btc_fetch()`,成功更新数据与时间戳),`build_data` 用 `concurrent.futures.wait` 整体最多等 **`FETCH_BUDGET`** 秒(默认 5,`.env` 可调),再由 `_settle` 结算:完成→`fetched(0.27s)`(带耗时)/失败沿用旧值 `stale`/从未成功占位 `none`;**未完成→ `slow→stale`(用上一次成功值)或 `slow→none`(占位)**,拉取线程继续跑完写入缓存供下次请求(stale-while-revalidate),`STATE.inflight[key]` 保证同一路进行中不重复实拉。响应上限 ≈ 预算 + 渲染,任一路上游卡死(socket 超时和风/橘喵 30s、BTC 8s)不再拖垮整图。**2026-09-14 前为串行实拉**(三路全拉约 1.1~2.6s,单路卡住即整体超时);改并行后三路全拉约 0.75s(取最慢一路)。日志每行末尾 `[weather:cache(12s) mao:fetched(0.06s) btc:slow→stale]` 可直接看命中/耗时/超预算情况,`/health` 有 `cache_age_s`/`ttl_s`/`fetch_budget_s`/`inflight`;用量取 `STATE.raw`(推送暂存)→ `data._usage(now, raw=…)`/`data._usage_gpt(now, raw=…)`,未推送时 Claude 全 `--`、ChatGPT 用 mock。端点:`GET IMAGE_PATH` → `render_png_bytes()`(`_RENDER_LOCK` 串行化,Pillow 渲染器非线程安全);`GET HTML_PATH` → `html_renderer.render_html()`;`GET HTML_PNG_PATH` → `html_shot.screenshot_html()`+`quantize6()`(`?raw=1` 跳过量化);`GET /health` JSON;`POST /push/usage|/push/usage_gpt`(body=原始快照 JSON,≤64KB,头 `X-Push-Token`==`PUSH_TOKEN`,为空拒绝);`GET /push/*` 查看暂存(同令牌)。所有响应 `Cache-Control: no-store`;每请求打一行日志(含 `CF-Connecting-IP`)。`PUSH_STATE_PATH` 非空时推送数据落盘(原子替换),重启恢复;图片永不落盘。实测:三路全实拉约 1.1~1.3s(天气 0.28s、橘喵 0.06s、OKX 0.7s 串行),全部命中缓存约 0.02s(渲染 20ms + 编码 14ms)。
|
- **`server.py`** — E1002 动态服务(stdlib `ThreadingHTTPServer`,无第三方依赖)。`build_data()` → `(data, 各路状态)`:日期按 `config.TZ_NAME` 时区现算;四路上游(天气/橘喵/BTC/设备遥测)经 `_cached(key, ttl, fetch)`——`STATE.last_good[key]` 未过期(`time - STATE.fetched_at[key] < ttl`)直接用;过期的**并行**提交到线程池 `_POOL`(`_refresh` 跑 `data.weather_fetch()`/`data.mao_fetch(now, with_trend=False)`/`data.btc_fetch()`,成功更新数据与时间戳),`build_data` 用 `concurrent.futures.wait` 整体最多等 **`FETCH_BUDGET`** 秒(默认 5,`.env` 可调),再由 `_settle` 结算:完成→`fetched(0.27s)`(带耗时)/失败沿用旧值 `stale`/从未成功占位 `none`;**未完成→ `slow→stale`(用上一次成功值)或 `slow→none`(占位)**,拉取线程继续跑完写入缓存供下次请求(stale-while-revalidate),`STATE.inflight[key]` 保证同一路进行中不重复实拉。响应上限 ≈ 预算 + 渲染,任一路上游卡死(socket 超时和风/橘喵 30s、BTC 8s)不再拖垮整图。**2026-09-14 前为串行实拉**(三路全拉约 1.1~2.6s,单路卡住即整体超时);改并行后三路全拉约 0.75s(取最慢一路)。日志每行末尾 `[weather:cache(12s) mao:fetched(0.06s) btc:slow→stale]` 可直接看命中/耗时/超预算情况,`/health` 有 `cache_age_s`/`ttl_s`/`fetch_budget_s`/`inflight`;用量取 `STATE.raw`(推送暂存)→ `data._usage(now, raw=…)`/`data._usage_gpt(now, raw=…)`,未推送时 Claude 全 `--`、ChatGPT 用 mock。端点:`GET IMAGE_PATH` → `render_png_bytes()`(`_RENDER_LOCK` 串行化,Pillow 渲染器非线程安全);**三个渲染端点在 `IMAGE_TOKEN` 非空时必须带 `?token=<IMAGE_TOKEN>`(`_page_authed`,`hmac.compare_digest`),否则返回与未知路径完全相同的 404、日志标 `bad page token`(2026-09-18 用户要求,防爬虫扫到固定路径);`/health` 不需要令牌**;`GET HTML_PATH` → `html_renderer.render_html()`;`GET HTML_PNG_PATH` → `html_shot.screenshot_html()`+`quantize6()`(`?raw=1` 跳过量化);`GET /health` JSON;`POST /push/usage|/push/usage_gpt`(body=原始快照 JSON,≤64KB,头 `X-Push-Token`==`PUSH_TOKEN`,为空拒绝);`GET /push/*` 查看暂存(同令牌)。所有响应 `Cache-Control: no-store`;每请求打一行日志(含 `CF-Connecting-IP`)。`PUSH_STATE_PATH` 非空时推送数据落盘(原子替换),重启恢复;图片永不落盘。实测:三路全实拉约 1.1~1.3s(天气 0.28s、橘喵 0.06s、OKX 0.7s 串行),全部命中缓存约 0.02s(渲染 20ms + 编码 14ms)。
|
||||||
- **`html_shot.py`** — 方案三:`screenshot_html(html)` 用无头 Chrome(`find_chrome()` 自动探测或 `CHROME_BIN`;Linux root 需 `CHROME_EXTRA_ARGS=--no-sandbox`)把 HTML 截成 800×480,临时目录用后即删,`_SHOT_LOCK` 串行化;**切勿传 `--user-data-dir` 指向全新目录**——新版 `--headless=new` 对空 profile 首启会卡死到超时(实测 A/B/D/E 全超时、只有不带它的 C 2 秒完成)。`quantize6(img)`:每通道二值化(阈值 `HTML_PNG_THRESHOLD` 默认 160,偏向保墨迹,细笔画浅灰边归黑而非丢白)→ Pillow 调色板 `quantize(dither=NONE)` 映射到 `SPECTRA6`,输出保证只含六色。服务端 `?raw=1` 返回未量化截图供对照。实测单次约 3.3s(Chrome 启动为主)。
|
- **`html_shot.py`** — 方案三:`screenshot_html(html)` 用无头 Chrome(`find_chrome()` 自动探测或 `CHROME_BIN`;Linux root 需 `CHROME_EXTRA_ARGS=--no-sandbox`)把 HTML 截成 800×480,临时目录用后即删,`_SHOT_LOCK` 串行化;**切勿传 `--user-data-dir` 指向全新目录**——新版 `--headless=new` 对空 profile 首启会卡死到超时(实测 A/B/D/E 全超时、只有不带它的 C 2 秒完成)。`quantize6(img)`:每通道二值化(阈值 `HTML_PNG_THRESHOLD` 默认 160,偏向保墨迹,细笔画浅灰边归黑而非丢白)→ Pillow 调色板 `quantize(dither=NONE)` 映射到 `SPECTRA6`,输出保证只含六色。服务端 `?raw=1` 返回未量化截图供对照。实测单次约 3.3s(Chrome 启动为主)。
|
||||||
- **`html_renderer.py`** — 方案二网页版:纯 HTML+CSS+内联 SVG、无 JS,数据服务端写死;字体栈 PingFang/微软雅黑/Noto Sans SC + Google Fonts 兜底(平台截图机若无中文字体会掉字,能否加载取决于其出网能力);颜色只用六色纯值;配色规则与 PNG 版一致(用量/pace 三档、涨红跌黑、K 线涨绿跌红),版式同构。K 线标题右侧价格用 `<strong>`(`h3 b::before` 的方块装饰只给标题)。
|
- **`html_renderer.py`** — 方案二网页版:纯 HTML+CSS+内联 SVG、无 JS,数据服务端写死;字体栈 PingFang/微软雅黑/Noto Sans SC + Google Fonts 兜底(平台截图机若无中文字体会掉字,能否加载取决于其出网能力);颜色只用六色纯值;配色规则与 PNG 版一致(用量/pace 三档、涨红跌黑、K 线涨绿跌红),版式同构。K 线标题右侧价格用 `<strong>`(`h3 b::before` 的方块装饰只给标题)。
|
||||||
- **`push_client.py`** — 本机推送端:`SOURCES` 两路——`claude_usage.get_usage()` → `POST {PUSH_URL}/push/usage`、`codex_usage.get_usage()` → `POST {PUSH_URL}/push/usage_gpt`(推前去掉 `status` 键);两路彼此独立,一路失败只打日志、两路都失败才非零退出;直连不走代理、连接类异常重试 2 次、4xx/5xx 直接报错。
|
- **`push_client.py`** — 本机推送端:`SOURCES` 两路——`claude_usage.get_usage()` → `POST {PUSH_URL}/push/usage`、`codex_usage.get_usage()` → `POST {PUSH_URL}/push/usage_gpt`(推前去掉 `status` 键);两路彼此独立,一路失败只打日志、两路都失败才非零退出;直连不走代理、连接类异常重试 2 次、4xx/5xx 直接报错。
|
||||||
@@ -91,20 +91,18 @@ EPD_HOST=epd400a44.local python3 main.py
|
|||||||
(`git show HEAD:renderer.py > /tmp/orig/renderer.py`,切换 `sys.path` 分别 import 渲染,`ImageChops.difference(a,b).getbbox() is None`);
|
(`git show HEAD:renderer.py > /tmp/orig/renderer.py`,切换 `sys.path` 分别 import 渲染,`ImageChops.difference(a,b).getbbox() is None`);
|
||||||
六色版渲染后 `set(img.getcolors())` 必须 ⊆ `SPECTRA6`。夹具需覆盖:pct=None、≥warn 红档、≤ok 绿档、direction=None、空 candles。
|
六色版渲染后 `set(img.getcolors())` 必须 ⊆ `SPECTRA6`。夹具需覆盖:pct=None、≥warn 红档、≤ok 绿档、direction=None、空 candles。
|
||||||
- **HTML→截图逐像素核验**:`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 --window-size=800,480 --screenshot=/tmp/s.png file://…`;**勿加指向空目录的 `--user-data-dir`**(卡死到超时)。
|
- **HTML→截图逐像素核验**:`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 --window-size=800,480 --screenshot=/tmp/s.png file://…`;**勿加指向空目录的 `--user-data-dir`**(卡死到超时)。
|
||||||
- **server.py / cloudflared 常驻(2026-09-15 起走 launchd)**:`~/Library/LaunchAgents/com.jm.eink-server.plist`(vfox python 绝对路径跑 `server.py`)与 `com.jm.eink-cloudflared.plist`(`cloudflared tunnel --url http://127.0.0.1:8790 --no-autoupdate --protocol http2`),均 `RunAtLoad`+`KeepAlive`,日志仍落 `output/server.log`/`output/cloudflared.log`;机器专用、未入库。**为什么**:此前两者在 Claude Code 工具 shell 里 `nohup … &` 启动,会话回收时被同一秒 SIGTERM(2026-09-15 10:11 两进程同时退出),电脑重启后也不会自起(当天早上重启即中断)。重启/查看:`launchctl kickstart -k gui/$(id -u)/com.jm.eink-cloudflared`(隧道地址会变,需更新 SenseCraft)、`launchctl list | grep com.jm.eink`;改代码后 kickstart server 即生效。改 plist 需 `launchctl bootout gui/$(id -u)/<label>` 再 `bootstrap`。以下手工命令仅作应急参考:
|
- **server.py 生产部署:NGCQ 服务器(2026-09-18 起)**,SSH 别名 `njcq`(Ubuntu 22.04,腾讯云境内,用户 `ubuntu` 免密 sudo)。公网地址 **`https://eink.njcqit.com`**(SenseCraft HTML 控件填 `https://eink.njcqit.com/e1002.png?token=<IMAGE_TOKEN>`,令牌见两端 `.env` 的 `IMAGE_TOKEN`(本机与服务器一致);本机 `.env` 的 `PUSH_URL=https://eink.njcqit.com`)。组成:
|
||||||
- **server.py 手工启动(应急)**:`nohup env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY python3 server.py >> output/server.log 2>&1 &`;
|
- 代码在 `/home/ubuntu/eink-push`(rsync `git ls-files` 的 24 个文件 + `.env` 0600,不含 output/缓存;`.env` 由本机 `.env` 派生:`SERVER_HOST=127.0.0.1`、`PUSH_STATE_PATH=/home/ubuntu/eink-push/output/push_state.json`、`PUSH_URL` 留空、`EPD_HOST`/`CODEX_*` 留空、另加 `BTC_SOURCES=gateio,huobi_aws,binance_vision`)。系统 `/usr/bin/python3` 3.10 + 用户级 Pillow 12.2(`~/.local`,无 python3-venv 包故不建 venv)。
|
||||||
重启前按 PID 杀干净:`for p in $(lsof -nP -iTCP:8790 -sTCP:LISTEN -t); do kill $p; done`——`pkill -f` 曾漏杀,且 `*:8790` 与 `127.0.0.1:8790` 两个监听可并存、旧代码继续应答(表现为新端点 404)。
|
- 进程:systemd **`eink-server.service`**(`/etc/systemd/system/`,`User=ubuntu`、`Restart=always`、日志追加 `output/server.log`)。改代码后:本机 rsync 同上 → `ssh njcq sudo systemctl restart eink-server`;查看 `ssh njcq 'systemctl status eink-server; tail output/server.log; curl -s 127.0.0.1:8790/health'`。
|
||||||
- **cloudflared 快速隧道**(免账号,地址每次重启都变,需到 SenseCraft 更新 URL):
|
- 反代与证书走 **1Panel(v2.2.4,OpenResty 容器 host 网络)**:站点 `eink.njcqit.com`(id 10,类型 proxy → `http://127.0.0.1:8790`,默认分组)+ Let's Encrypt 证书(ssl id 11,EC256,`dnsAccount` 腾讯云 DNS 账号 id 1 + acme 账号 id 1,自动续期,与其它 njcqit.com 站点同一套);HTTP 301 到 HTTPS、HSTS。生成的配置:`/opt/1panel/www/conf.d/eink.njcqit.com.conf` + `/opt/1panel/www/sites/eink.njcqit.com/proxy/root.conf`。建站时用的是 **1Panel API**(面板已开 API、白名单 127.0.0.1,浏览器扩展当时未连接):服务器上留有辅助脚本 `~/.1p_api.sh METHOD /api/v2/path [json]`(运行时从 `/opt/1panel/db/core.db` 取 ApiKey 算 `1Panel-Token=md5("1panel"+key+ts)`,不落盘不回显)。**坑**:v2.2.4 的建站请求体与 dev 分支 swagger 不同——用 `domains:[{domain,port,ssl}]` 而非 `primaryDomain`(发 `primaryDomain` 直接 500 空响应),可 `enableSSL:true, websiteSSLID` 一步挂证书;结构体以 `raw.githubusercontent.com/1Panel-dev/1Panel/v2.2.4/agent/app/dto/request/website.go` 为准。建站后配置未即时生效(拿到面板默认证书、HTTP 404),`docker exec 1Panel-openresty-wYew nginx -t` 通过后几秒内自行生效。
|
||||||
`nohup env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY cloudflared tunnel --url http://127.0.0.1:8790 --no-autoupdate --protocol http2 > output/cloudflared.log 2>&1 &`,
|
- **境内上游可达性**:JM 后端、和风、SenseCraft 都通;**OKX/Coinbase/Huobi 主域名全部不通且各超时 4~8s**(合计超 `FETCH_BUDGET`,K 线永远拿不到),故 `btc_api.py` 新增 `gateio`/`huobi_aws`(api-aws.huobi.pro)/`binance_vision`(data-api.binance.vision)三源并加 `BTC_SOURCES` 顺序配置(config.py);本机默认顺序不变。
|
||||||
地址 `grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' output/cloudflared.log`(已写入 `output/tunnel_url.txt`)。
|
- 该服务器原有 2026-06 部署的 **旧 main 分支 Zectrix 版**(每分钟 cron 推 Zectrix 设备 44:1B:F6:89:C7:78),已备份为 `~/eink-push.legacy-zectrix`、cron 行注释保留(`crontab -l` 可见),本分支无 Zectrix 推送。
|
||||||
**快速隧道会被 Cloudflare 侧静默回收**(2026-09-13 一条跑了约 2 天后失效):进程仍在、server 仍正常,但日志循环报 `Unauthorized: Tunnel not found`,公网地址 530。
|
- **本机不再跑 server/cloudflared**:`com.jm.eink-server`/`com.jm.eink-cloudflared` 两个 LaunchAgent 已 bootout 并删除 plist;只保留 `com.jm.eink-push`(小屏推送 + 用量上报)。历史坑(cloudflared 快速隧道会被 Cloudflare 静默回收、SIGTERM 退出慢、需 `--protocol http2`)已不再相关。
|
||||||
「设备收不到数据」先看 `output/cloudflared.log` 而不是 server;修复只能重启 cloudflared 拿新地址并更新 SenseCraft。
|
|
||||||
重启要点:旧进程对 SIGTERM 退出很慢(数秒),确认 `ps aux | grep "cloudflared tunnel"` 已清空再起新的;默认 QUIC 注册曾 `context deadline exceeded` 后自行退出,加 `--protocol http2` 一次成功。
|
|
||||||
- **shell 坑**:本机是 fish——`env -u …` 不能存进变量再当命令执行;含中文的 Python heredoc 用 `python3 -X utf8 - <<'EOF'`,否则 stdin 解码报错;`gh` 未登录且 GitHub API 匿名限流,取源码走 `curl raw.githubusercontent.com`。
|
- **shell 坑**:本机是 fish——`env -u …` 不能存进变量再当命令执行;含中文的 Python heredoc 用 `python3 -X utf8 - <<'EOF'`,否则 stdin 解码报错;`gh` 未登录且 GitHub API 匿名限流,取源码走 `curl raw.githubusercontent.com`。
|
||||||
- **用量采集验证**:`python3 claude_usage.py --force` 应打出 `status: fetched` 且 `seven_day_fable` 非空;紧接着不带 `--force` 再跑应为 `cache`;缓存文件里不得出现 token。`python3 codex_usage.py --force` 未登录时报「authentication required…请先执行 CODEX_HOME=… codex login」,登录后应有 `five_hour`/`seven_day`。历史:statusline 补丁(`~/.claude/statusline.sh`)仍在本机产出 `~/.claude/usage-snapshot.json`,但本项目已不读它。
|
- **用量采集验证**:`python3 claude_usage.py --force` 应打出 `status: fetched` 且 `seven_day_fable` 非空;紧接着不带 `--force` 再跑应为 `cache`;缓存文件里不得出现 token。`python3 codex_usage.py --force` 未登录时报「authentication required…请先执行 CODEX_HOME=… codex login」,登录后应有 `five_hour`/`seven_day`。历史:statusline 补丁(`~/.claude/statusline.sh`)仍在本机产出 `~/.claude/usage-snapshot.json`,但本项目已不读它。
|
||||||
- **定时任务**:`run.sh` 末尾已追加 `main.py --target push`(vfox python 绝对路径,`|| true`),Claude/Codex 两路账户级用量随喵喵推送每 5 分钟采集并同步到 server.py;推送暂存落盘 `output/push_state.json`。
|
- **定时任务**:`run.sh` 末尾已追加 `main.py --target push`(vfox python 绝对路径,`|| true`),Claude/Codex 两路账户级用量随喵喵推送每 5 分钟采集并同步到 server.py;推送暂存落盘 `output/push_state.json`。
|
||||||
- **上游配额**:server 三路上游已有 TTL 内存缓存(天气默认 15 分钟 → 和风约 192 次/天);改有效期只动 `.env`,不动代码。本机 `.env` 当前 `MAO_TTL=0`/`BTC_TTL=0`(每次请求实拉)。
|
- **上游配额**:server 三路上游已有 TTL 内存缓存(天气默认 15 分钟 → 和风约 192 次/天);改有效期只动 `.env`,不动代码。本机 `.env` 当前 `MAO_TTL=0`/`BTC_TTL=0`(每次请求实拉)。
|
||||||
- **「平台抓取失败」排查顺序**:① `output/cloudflared.log` 隧道是否被回收;② `output/server.log` 该时刻有无该请求(平台 IP 13.91.x.x,每 ~5m45s 一次)——没有记录说明请求根本没到我们这里,问题在平台/隧道;③ 有记录看末尾状态串与耗时:`slow→` 表示某路超 `FETCH_BUDGET`、本次用了旧值。2026-09-14 一次怀疑「多路 TTL 同时到期导致超时」,核查日志:所有平台请求均 200、最慢 4.23s 且该次天气还是缓存,三路同时实拉的两次仅 1.2~1.5s——猜想不成立(MAO/BTC TTL=0 本就每次同时实拉);真正的隐患是当时串行拉取 + 30s socket 超时,已改并行 + 预算。
|
- **「平台抓取失败」排查顺序**:① `ssh njcq 'systemctl is-active eink-server; curl -s -o /dev/null -w "%{http_code}" https://eink.njcqit.com/health'` 服务与反代是否活着(1Panel 站点/证书状态在面板「网站」里看);② 服务器 `output/server.log` 该时刻有无该请求(平台 IP 13.91.x.x,每 ~5m45s 一次)——没有记录说明请求根本没到我们这里,问题在平台/Nginx;③ 有记录看末尾状态串与耗时:`slow→` 表示某路超 `FETCH_BUDGET`、本次用了旧值(境内 BTC 源见上)。2026-09-14 一次怀疑「多路 TTL 同时到期导致超时」,核查日志:所有平台请求均 200、最慢 4.23s 且该次天气还是缓存,三路同时实拉的两次仅 1.2~1.5s——猜想不成立(MAO/BTC TTL=0 本就每次同时实拉);真正的隐患是当时串行拉取 + 30s socket 超时,已改并行 + 预算。
|
||||||
- **SenseCraft 未公开后端**(从网页前端 bundle 提取,`https://sensecraft-hmi-api.seeed.cc`,头 `Authorization: <token>`,token 在 localStorage `user-storage`):
|
- **SenseCraft 未公开后端**(从网页前端 bundle 提取,`https://sensecraft-hmi-api.seeed.cc`,头 `Authorization: <token>`,token 在 localStorage `user-storage`):
|
||||||
`POST /api/v1/oss/file/upload`(multipart `file`+`type=image`→`file_url`)→ `POST /api/v2/user/page`(`{pages:[{name,type:img|url|layout,data,dither,thumbnail,resolution:"800x480"}]}`)
|
`POST /api/v1/oss/file/upload`(multipart `file`+`type=image`→`file_url`)→ `POST /api/v2/user/page`(`{pages:[{name,type:img|url|layout,data,dither,thumbnail,resolution:"800x480"}]}`)
|
||||||
→ `POST /api/v2/user/playlist/upsert_pages` → `POST /api/v2/user/device/down_link`(`{mac_address,playlist_id,type,refresh_interval?,deep_sleep_enabled?}`);
|
→ `POST /api/v2/user/playlist/upsert_pages` → `POST /api/v2/user/device/down_link`(`{mac_address,playlist_id,type,refresh_interval?,deep_sleep_enabled?}`);
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ python3 main.py --target e1002 # reTerminal E1002(800x480 六色):产出 o
|
|||||||
python3 server.py # reTerminal E1002 动态服务(部署公网 HTTPS):/e1002.png 每次请求内存渲染 PNG,/e1002.html 现代网页,
|
python3 server.py # reTerminal E1002 动态服务(部署公网 HTTPS):/e1002.png 每次请求内存渲染 PNG,/e1002.html 现代网页,
|
||||||
# /e1002-web.png 网页经服务端无头 Chrome 截图后固化六色(需装 Chrome);
|
# /e1002-web.png 网页经服务端无头 Chrome 截图后固化六色(需装 Chrome);
|
||||||
# 天气/橘喵/BTC 实时拉取,Claude/ChatGPT 用量由本机推送
|
# 天气/橘喵/BTC 实时拉取,Claude/ChatGPT 用量由本机推送
|
||||||
python3 main.py --target push # 本机:推送 ~/.claude/usage-snapshot.json 到 server.py(.env: PUSH_URL + PUSH_TOKEN)
|
python3 main.py --target push # 本机:采集 Claude/Codex 账户级用量并推送到 server.py(.env: PUSH_URL + PUSH_TOKEN)
|
||||||
|
# server.py 生产部署在 NGCQ 服务器(systemd eink-server + 1Panel Nginx 反代 https://eink.njcqit.com),见 CLAUDE.md
|
||||||
```
|
```
|
||||||
|
|
||||||
环境变量可覆盖配置(详见 `config.py`):
|
环境变量可覆盖配置(详见 `config.py`):
|
||||||
|
|||||||
52
btc_api.py
52
btc_api.py
@@ -7,7 +7,13 @@
|
|||||||
1. OKX GET /api/v5/market/candles?instId=BTC-USDT&bar=<1H|1D> —— 主源,本地直连可达、数据最新
|
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 数值几乎一致)
|
2. Coinbase GET /products/BTC-USD/candles?granularity=<3600|86400> —— 备源(USD 计价,与 USDT 数值几乎一致)
|
||||||
3. Huobi GET /market/history/kline?period=<60min|1day>&symbol=btcusdt —— 备源
|
3. Huobi GET /market/history/kline?period=<60min|1day>&symbol=btcusdt —— 备源
|
||||||
(Binance 在本地区返回 restricted location,不列入。)
|
4. Gate.io GET /api/v4/spot/candlesticks?currency_pair=BTC_USDT&interval=<1h|1d>
|
||||||
|
5. Huobi AWS 同 3,域名 api-aws.huobi.pro
|
||||||
|
6. Binance GET data-api.binance.vision/api/v3/klines?symbol=BTCUSDT&interval=<1h|1d>(公开行情镜像域名)
|
||||||
|
(api.binance.com 在本地区返回 restricted location,不列入。)
|
||||||
|
|
||||||
|
顺序可用 BTC_SOURCES(逗号分隔源名)覆盖:境内服务器上 okx/coinbase/huobi 主域名均不通且逐个超时(合计 >15s,
|
||||||
|
超出 FETCH_BUDGET),须把 gateio/huobi_aws/binance_vision 排在前面(2026-09-18 部署 NGCQ 实测)。
|
||||||
|
|
||||||
周期用统一代号 bar:"1H"(时 K)/ "1D"(日 K),由 BARS 映射到各源的写法。
|
周期用统一代号 bar:"1H"(时 K)/ "1D"(日 K),由 BARS 映射到各源的写法。
|
||||||
统一输出:按时间**升序**的 list[dict],每根 {"ts": epoch 秒, "o", "h", "l", "c"}(float),最后一根为当前未收盘的那根。
|
统一输出:按时间**升序**的 list[dict],每根 {"ts": epoch 秒, "o", "h", "l", "c"}(float),最后一根为当前未收盘的那根。
|
||||||
@@ -19,8 +25,8 @@ import urllib.request
|
|||||||
TIMEOUT = 8
|
TIMEOUT = 8
|
||||||
UA = "eink-push/1.0 (+https://github.com/)"
|
UA = "eink-push/1.0 (+https://github.com/)"
|
||||||
|
|
||||||
# 周期代号 → 各源参数:(OKX bar, Coinbase granularity 秒, Huobi period)
|
# 周期代号 → 各源参数:(OKX bar, Coinbase granularity 秒, Huobi period, Gate.io/Binance interval)
|
||||||
BARS = {"1H": ("1H", 3600, "60min"), "1D": ("1D", 86400, "1day")}
|
BARS = {"1H": ("1H", 3600, "60min", "1h"), "1D": ("1D", 86400, "1day", "1d")}
|
||||||
|
|
||||||
|
|
||||||
def _get_json(url):
|
def _get_json(url):
|
||||||
@@ -45,8 +51,8 @@ def _coinbase(bar, limit):
|
|||||||
return sorted(out, key=lambda x: x["ts"])[-limit:]
|
return sorted(out, key=lambda x: x["ts"])[-limit:]
|
||||||
|
|
||||||
|
|
||||||
def _huobi(bar, limit):
|
def _huobi(bar, limit, host="api.huobi.pro"):
|
||||||
j = _get_json(f"https://api.huobi.pro/market/history/kline?period={BARS[bar][2]}&size={limit}&symbol=btcusdt")
|
j = _get_json(f"https://{host}/market/history/kline?period={BARS[bar][2]}&size={limit}&symbol=btcusdt")
|
||||||
if j.get("status") != "ok":
|
if j.get("status") != "ok":
|
||||||
raise RuntimeError(f"huobi status={j.get('status')} err={j.get('err-msg')}")
|
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"])}
|
out = [{"ts": int(r["id"]), "o": float(r["open"]), "h": float(r["high"]), "l": float(r["low"]), "c": float(r["close"])}
|
||||||
@@ -54,7 +60,39 @@ def _huobi(bar, limit):
|
|||||||
return sorted(out, key=lambda x: x["ts"])
|
return sorted(out, key=lambda x: x["ts"])
|
||||||
|
|
||||||
|
|
||||||
SOURCES = (("okx", _okx), ("coinbase", _coinbase), ("huobi", _huobi))
|
def _huobi_aws(bar, limit):
|
||||||
|
return _huobi(bar, limit, host="api-aws.huobi.pro")
|
||||||
|
|
||||||
|
|
||||||
|
def _gateio(bar, limit):
|
||||||
|
rows = _get_json(f"https://api.gateio.ws/api/v4/spot/candlesticks?currency_pair=BTC_USDT&interval={BARS[bar][3]}&limit={limit}")
|
||||||
|
# 升序:[ts 秒, 计价币成交额, close, high, low, open, 基础币成交量, 是否收盘]
|
||||||
|
out = [{"ts": int(r[0]), "o": float(r[5]), "h": float(r[3]), "l": float(r[4]), "c": float(r[2])} for r in rows]
|
||||||
|
return sorted(out, key=lambda x: x["ts"])
|
||||||
|
|
||||||
|
|
||||||
|
def _binance_vision(bar, limit):
|
||||||
|
rows = _get_json(f"https://data-api.binance.vision/api/v3/klines?symbol=BTCUSDT&interval={BARS[bar][3]}&limit={limit}")
|
||||||
|
# 升序:[openTime ms, o, h, l, c, vol, closeTime, ...]
|
||||||
|
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"])
|
||||||
|
|
||||||
|
|
||||||
|
SOURCES = (("okx", _okx), ("coinbase", _coinbase), ("huobi", _huobi),
|
||||||
|
("gateio", _gateio), ("huobi_aws", _huobi_aws), ("binance_vision", _binance_vision))
|
||||||
|
_BY_NAME = dict(SOURCES)
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_sources():
|
||||||
|
"""按 config.BTC_SOURCES(逗号分隔源名)排序;未列出的源按默认顺序追加到末尾,未知名字忽略。"""
|
||||||
|
try:
|
||||||
|
import config
|
||||||
|
wanted = [s.strip() for s in config.BTC_SOURCES.split(",") if s.strip()]
|
||||||
|
except Exception: # noqa: BLE001 —— 单独运行本模块或 config 缺失时用默认顺序
|
||||||
|
wanted = []
|
||||||
|
ordered = [(n, _BY_NAME[n]) for n in wanted if n in _BY_NAME]
|
||||||
|
ordered += [(n, f) for n, f in SOURCES if n not in dict(ordered)]
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
def get_candles(bar="1H", limit=36):
|
def get_candles(bar="1H", limit=36):
|
||||||
@@ -62,7 +100,7 @@ def get_candles(bar="1H", limit=36):
|
|||||||
if bar not in BARS:
|
if bar not in BARS:
|
||||||
raise ValueError(f"不支持的周期 {bar!r},可选:{list(BARS)}")
|
raise ValueError(f"不支持的周期 {bar!r},可选:{list(BARS)}")
|
||||||
errors = []
|
errors = []
|
||||||
for name, fn in SOURCES:
|
for name, fn in _ordered_sources():
|
||||||
try:
|
try:
|
||||||
rows = fn(bar, limit)
|
rows = fn(bar, limit)
|
||||||
if len(rows) >= 2:
|
if len(rows) >= 2:
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ CHROME_BIN = os.environ.get("CHROME_BIN", "") # 为空则按
|
|||||||
CHROME_EXTRA_ARGS = os.environ.get("CHROME_EXTRA_ARGS", "") # 例如 Linux 以 root 运行需 "--no-sandbox"
|
CHROME_EXTRA_ARGS = os.environ.get("CHROME_EXTRA_ARGS", "") # 例如 Linux 以 root 运行需 "--no-sandbox"
|
||||||
# 每通道二值化阈值(0~255):低于阈值归 0、否则归 255;取 160(而非 128)偏向保留墨迹,细笔画的浅灰边不至于被丢成白
|
# 每通道二值化阈值(0~255):低于阈值归 0、否则归 255;取 160(而非 128)偏向保留墨迹,细笔画的浅灰边不至于被丢成白
|
||||||
HTML_PNG_THRESHOLD = int(os.environ.get("HTML_PNG_THRESHOLD", "160"))
|
HTML_PNG_THRESHOLD = int(os.environ.get("HTML_PNG_THRESHOLD", "160"))
|
||||||
|
# 页面访问令牌:非空时 IMAGE_PATH/HTML_PATH/HTML_PNG_PATH 必须带 ?token=<IMAGE_TOKEN>,否则一律 404(对扫描器等同不存在)。
|
||||||
|
# 服务在公网且路径固定,SenseCraft 里填 https://<域名>/e1002.png?token=<IMAGE_TOKEN>(2026-09-18 用户要求,防爬虫扫到)
|
||||||
|
IMAGE_TOKEN = os.environ.get("IMAGE_TOKEN", "")
|
||||||
PUSH_TOKEN = os.environ.get("PUSH_TOKEN", "") # 推送鉴权令牌(X-Push-Token 头);为空则拒绝一切推送
|
PUSH_TOKEN = os.environ.get("PUSH_TOKEN", "") # 推送鉴权令牌(X-Push-Token 头);为空则拒绝一切推送
|
||||||
PUSH_STATE_PATH = os.environ.get("PUSH_STATE_PATH", "") # 推送数据落盘路径(重启后恢复);为空=仅内存
|
PUSH_STATE_PATH = os.environ.get("PUSH_STATE_PATH", "") # 推送数据落盘路径(重启后恢复);为空=仅内存
|
||||||
# 本机推送端(main.py --target push):服务地址(含协议与端口,不含路径),令牌同 PUSH_TOKEN
|
# 本机推送端(main.py --target push):服务地址(含协议与端口,不含路径),令牌同 PUSH_TOKEN
|
||||||
@@ -105,6 +108,9 @@ TREND_CACHE_PATH = os.path.join(BASE_DIR, ".trend_cache.json")
|
|||||||
BTC_BAR = os.environ.get("BTC_BAR", "1H") # 周期:"1H" 时K / "1D" 日K
|
BTC_BAR = os.environ.get("BTC_BAR", "1H") # 周期:"1H" 时K / "1D" 日K
|
||||||
BTC_LIMIT = int(os.environ.get("BTC_LIMIT", "48")) # 展示最近 N 根(时 K 默认 48 = 近 2 天;2026-09-14 由 72 改 48)
|
BTC_LIMIT = int(os.environ.get("BTC_LIMIT", "48")) # 展示最近 N 根(时 K 默认 48 = 近 2 天;2026-09-14 由 72 改 48)
|
||||||
BTC_TTL = int(os.environ.get("BTC_TTL", "300")) # 秒,默认 5 分钟(本机文件缓存与 server.py 内存缓存共用)
|
BTC_TTL = int(os.environ.get("BTC_TTL", "300")) # 秒,默认 5 分钟(本机文件缓存与 server.py 内存缓存共用)
|
||||||
|
# 行情源尝试顺序(逗号分隔,见 btc_api.SOURCES;未列出的按默认顺序追加)。境内服务器 okx/coinbase/huobi 主域名不通且各超时数秒,
|
||||||
|
# 部署 NGCQ 时设 BTC_SOURCES=gateio,huobi_aws,binance_vision,否则每次请求都超 FETCH_BUDGET 拿不到 K 线。
|
||||||
|
BTC_SOURCES = os.environ.get("BTC_SOURCES", "okx,coinbase,huobi,gateio,huobi_aws,binance_vision")
|
||||||
BTC_CACHE_PATH = os.path.join(BASE_DIR, ".btc_cache.json")
|
BTC_CACHE_PATH = os.path.join(BASE_DIR, ".btc_cache.json")
|
||||||
|
|
||||||
# ---- reTerminal E1002 设备遥测(SenseCraft HMI,电量/温度/湿度,宽版顶部状态栏)----
|
# ---- reTerminal E1002 设备遥测(SenseCraft HMI,电量/温度/湿度,宽版顶部状态栏)----
|
||||||
|
|||||||
17
server.py
17
server.py
@@ -20,14 +20,17 @@ HTML 控件填同一个 URL 则**每个刷新周期都重抓**。所以对外只
|
|||||||
- 三路上游过期时**并行**实拉,整体最多等 FETCH_BUDGET 秒(默认 5),超预算的一路本次用旧值、后台拉完更新缓存(见 build_data);
|
- 三路上游过期时**并行**实拉,整体最多等 FETCH_BUDGET 秒(默认 5),超预算的一路本次用旧值、后台拉完更新缓存(见 build_data);
|
||||||
所以推送时机与设备抓取时机不必对齐。未推送过:Claude 显示 "--",ChatGPT 用 mock 并标「示例数据」。
|
所以推送时机与设备抓取时机不必对齐。未推送过:Claude 显示 "--",ChatGPT 用 mock 并标「示例数据」。
|
||||||
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKEN;PUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。
|
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKEN;PUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。
|
||||||
|
- 页面鉴权:IMAGE_TOKEN 非空时,三个渲染端点必须带 ?token=<IMAGE_TOKEN>,否则返回与未知路径一样的 404(防爬虫扫描)。
|
||||||
其他端点:GET /health → JSON 运行状态;GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。
|
其他端点:GET /health → JSON 运行状态;GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。
|
||||||
部署:python3 server.py(监听 SERVER_HOST:SERVER_PORT),前置 Caddy/Nginx 做 HTTPS,或 cloudflared tunnel。
|
部署:python3 server.py(监听 SERVER_HOST:SERVER_PORT),前置 Caddy/Nginx 做 HTTPS,或 cloudflared tunnel。
|
||||||
"""
|
"""
|
||||||
|
import hmac
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
from concurrent.futures import ThreadPoolExecutor, wait
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
@@ -221,12 +224,24 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def _authed(self):
|
def _authed(self):
|
||||||
tok = config.PUSH_TOKEN
|
tok = config.PUSH_TOKEN
|
||||||
return bool(tok) and self.headers.get("X-Push-Token", "") == tok
|
return bool(tok) and hmac.compare_digest(self.headers.get("X-Push-Token", ""), tok)
|
||||||
|
|
||||||
|
def _page_authed(self):
|
||||||
|
"""页面令牌:IMAGE_TOKEN 为空则放行;否则查询串里 token 必须相等(常量时间比较)。"""
|
||||||
|
tok = config.IMAGE_TOKEN
|
||||||
|
if not tok:
|
||||||
|
return True
|
||||||
|
qs = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
||||||
|
return any(hmac.compare_digest(v, tok) for v in qs.get("token", []))
|
||||||
|
|
||||||
# ---------- GET ----------
|
# ---------- GET ----------
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
path = self.path.split("?", 1)[0]
|
path = self.path.split("?", 1)[0]
|
||||||
try:
|
try:
|
||||||
|
if path in (config.IMAGE_PATH, config.HTML_PATH, config.HTML_PNG_PATH) and not self._page_authed():
|
||||||
|
# 令牌不对与路径不存在返回完全一样的 404,不暴露端点存在
|
||||||
|
self._log(404, "bad page token")
|
||||||
|
return self._json(404, {"ok": False, "error": "not found"})
|
||||||
if path == config.IMAGE_PATH:
|
if path == config.IMAGE_PATH:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
d, st = build_data()
|
d, st = build_data()
|
||||||
|
|||||||
Reference in New Issue
Block a user