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:
2026-09-18 11:42:23 +08:00
parent 7c60d3680c
commit 04fa31cb94
6 changed files with 86 additions and 22 deletions

View File

@@ -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

View File

@@ -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 passwordservice `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 passwordservice `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 账号时 errorcodex account authentication required to read rate limits」。**窗口归类按 `windowDurationMins`**(≤12h five_hour,≥5 seven_dayPro 计划常只回一个窗口另一个 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 账号时 errorcodex account authentication required to read rate limits」。**窗口归类按 `windowDurationMins`**(≤12h five_hour,≥5 seven_dayPro 计划常只回一个窗口另一个 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 顶部 090 **大时钟 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` 10386竖规 x=400 `usage_gpt` 414790`_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+26102区块底 y0+104D `_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 顶部 090 **大时钟 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` 10386竖规 x=400 `usage_gpt` 414790`_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+26102区块底 y0+104D `_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 超时和风/橘喵 30sBTC 8s不再拖垮整图。**2026-09-14 前为串行实拉**三路全拉约 1.12.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.11.3s天气 0.28s橘喵 0.06sOKX 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 超时和风/橘喵 30sBTC 8s不再拖垮整图。**2026-09-14 前为串行实拉**三路全拉约 1.12.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.11.3s天气 0.28s橘喵 0.06sOKX 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.3sChrome 启动为主)。 - **`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.3sChrome 启动为主)。
- **`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 … &` 启动会话回收时被同一秒 SIGTERM2026-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 - 反代与证书走 **1Panelv2.2.4OpenResty 容器 host 网络)**站点 `eink.njcqit.com`id 10类型 proxy `http://127.0.0.1:8790`默认分组+ Let's Encrypt 证书ssl id 11EC256`dnsAccount` 腾讯云 DNS 账号 id 1 + acme 账号 id 1自动续期与其它 njcqit.com 站点同一套HTTP 301 HTTPSHSTS生成的配置`/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 都通**OKXCoinbaseHuobi 主域名全部不通且各超时 48s**合计超 `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.21.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.21.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?}`

View File

@@ -57,7 +57,8 @@ python3 main.py --target e1002 # reTerminal E1002800x480 六色):产出 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`

View File

@@ -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:

View File

@@ -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电量/温度/湿度,宽版顶部状态栏)----

View File

@@ -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_TOKENPUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。 - 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKENPUSH_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()