feat: OpenCode Go usage monitor — hybrid collector + 246-centric dashboard
- usage_collector.py v3.0: HTTP+stored cookies (primary) + CDP (bonus)
- Cookie keepalive: captures Set-Cookie from responses, saves back to JSON
- Per-metric regex for SSR parsing (handles nested \[N]={...} structure)
- Cache merge: preserves other accounts' data on partial collection
- extract_cookies.py: one-time CDP /cookies endpoint → per-account JSON
- dashboard.py: /api/usage reads local file (no Windows bot dependency)
- /api/usage/refresh: local subprocess collector
- Auto-timer: 5min background thread (cookie keepalive + fresh data)
- xmpp_agent_core.py: /usage endpoint + auto-timer (for local testing)
- dashboard.html: usage-monitor section (4 cards × 3 metrics + alerts)
- accounts.json: 4 accounts configured (key1-4, gitignored)
- .gitignore: cookies/, accounts.json, usage_stats.json
This commit is contained in:
@@ -21,3 +21,8 @@ mowechat.conf
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.venv/
|
.venv/
|
||||||
|
# OpenCode Go Usage Monitor
|
||||||
|
gateway/scripts/usage_monitor/cookies/
|
||||||
|
gateway/scripts/usage_monitor/accounts.json
|
||||||
|
gateway/temp/usage_stats.json
|
||||||
|
gateway/logs/usage_collector.log
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Flask app on :5803. Monitors agents across platforms via:
|
|||||||
|
|
||||||
Auto-recovery: restarts local Windows agents after 3 consecutive offline checks.
|
Auto-recovery: restarts local Windows agents after 3 consecutive offline checks.
|
||||||
"""
|
"""
|
||||||
import os, sys, re, json, time, subprocess, logging, urllib.request, sqlite3, shutil
|
import os, sys, re, json, time, subprocess, logging, urllib.request, sqlite3, shutil, threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from flask import Flask, jsonify, request, send_from_directory
|
from flask import Flask, jsonify, request, send_from_directory
|
||||||
@@ -1032,6 +1032,85 @@ def api_rdp_toggle():
|
|||||||
return jsonify({"ok": False, "error": str(e)})
|
return jsonify({"ok": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# OpenCode Go Usage Monitor — 4个账号用量配额监控 (246 本地采集)
|
||||||
|
# See gateway/scripts/specs/usage_monitor.json
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
_USAGE_STATS_FILE = TEMP_DIR / "usage_stats.json"
|
||||||
|
_USAGE_COLLECTOR_SCRIPT = _SCRIPT_DIR / "usage_collector.py"
|
||||||
|
_usage_collector_running = False
|
||||||
|
|
||||||
|
@app.route("/api/usage")
|
||||||
|
def api_usage():
|
||||||
|
"""直读本地 usage_stats.json(秒级响应,不依赖 Windows bot)"""
|
||||||
|
if not _USAGE_STATS_FILE.exists():
|
||||||
|
return jsonify({"ok": False, "error": "no data yet (collect never ran)",
|
||||||
|
"last_refresh_iso": None, "accounts": []})
|
||||||
|
try:
|
||||||
|
with open(str(_USAGE_STATS_FILE), "r", encoding="utf-8") as f:
|
||||||
|
return jsonify(json.load(f))
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "error": f"read failed: {e}", "accounts": []})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/usage/refresh", methods=["POST"])
|
||||||
|
def api_usage_refresh():
|
||||||
|
"""触发本地采集(异步 subprocess,立即返回)"""
|
||||||
|
global _usage_collector_running
|
||||||
|
if _usage_collector_running:
|
||||||
|
return jsonify({"ok": True, "message": "collection already in-flight"})
|
||||||
|
_usage_collector_running = True
|
||||||
|
|
||||||
|
def _runner():
|
||||||
|
global _usage_collector_running
|
||||||
|
try:
|
||||||
|
log.info("usage: starting local collector subprocess")
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(_USAGE_COLLECTOR_SCRIPT)],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
log.info(f"usage: collector done (rc={proc.returncode}, "
|
||||||
|
f"stdout={len(proc.stdout)}, stderr={len(proc.stderr)})")
|
||||||
|
if proc.returncode != 0 and proc.stderr:
|
||||||
|
log.warning(f"usage_collector stderr:\n{proc.stderr[-1000:]}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"usage: collector crash: {e}")
|
||||||
|
finally:
|
||||||
|
_usage_collector_running = False
|
||||||
|
|
||||||
|
t = threading.Thread(target=_runner, name="usage_collector", daemon=True)
|
||||||
|
t.start()
|
||||||
|
return jsonify({"ok": True, "message": "collection triggered (~10s), poll /api/usage shortly"})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auto-collect timer (every 5 min, keeps cookies alive) ──
|
||||||
|
_USAGE_AUTO_INTERVAL = 300
|
||||||
|
|
||||||
|
def _start_usage_auto_timer():
|
||||||
|
"""后台线程:每 5 分钟自动采集 + cookie 保活。246 自足,不依赖 Windows。"""
|
||||||
|
def _loop():
|
||||||
|
time.sleep(15) # initial delay: let dashboard stabilize
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
global _usage_collector_running
|
||||||
|
if not _usage_collector_running:
|
||||||
|
_usage_collector_running = True
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(_USAGE_COLLECTOR_SCRIPT)],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
log.info(f"usage auto-timer: rc={proc.returncode}")
|
||||||
|
if proc.returncode != 0 and proc.stderr:
|
||||||
|
log.warning(f"usage auto-timer stderr:\n{proc.stderr[-800:]}")
|
||||||
|
_usage_collector_running = False
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"usage auto-timer error: {e}")
|
||||||
|
_usage_collector_running = False
|
||||||
|
time.sleep(_USAGE_AUTO_INTERVAL)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_loop, name="usage_auto_timer", daemon=True)
|
||||||
|
t.start()
|
||||||
|
log.info(f"usage auto-timer started (interval={_USAGE_AUTO_INTERVAL}s)")
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# Module Spec — 功能模块 co-located 文档
|
# Module Spec — 功能模块 co-located 文档
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
@@ -1164,6 +1243,7 @@ def main():
|
|||||||
port = int(os.environ.get("DASHBOARD_PORT", 5803))
|
port = int(os.environ.get("DASHBOARD_PORT", 5803))
|
||||||
log.info(f"Dashboard starting on :{port}")
|
log.info(f"Dashboard starting on :{port}")
|
||||||
print(f"[dashboard] Starting on http://127.0.0.1:{port}")
|
print(f"[dashboard] Starting on http://127.0.0.1:{port}")
|
||||||
|
_start_usage_auto_timer()
|
||||||
app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True)
|
app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"module": "usage_monitor",
|
||||||
|
"version": "2.0",
|
||||||
|
"purpose": "监控 4 个 OpenCode Go 订阅账号的用量配额 (5h rolling / weekly / monthly),避免跑超限额被限流至免费模型。通过 CDP proxy 在 Chrome tab 内执行 fetch(location.href, {credentials:'include'}) 获取 SSR HTML,浏览器自动带 httpOnly cookies,无需提取/存储 cookies。",
|
||||||
|
"ui_location": "I Tab (Infrastructure) → OpenCode Go Usage section",
|
||||||
|
|
||||||
|
"human_help": {
|
||||||
|
"title": "OpenCode Go Usage Monitor",
|
||||||
|
"description": [
|
||||||
|
"4 个 OpenCode Go 订阅账号 (每个对应独立的 OAuth 登录: 2 Google + 2 GitHub) 的实时用量监控。",
|
||||||
|
"每个账号采集 3 个指标: 5h rolling (5小时滚动用量) + weekly (周用量) + monthly (月用量),含百分比+到期倒计时。",
|
||||||
|
"数据源: opencode.ai 的 SSR hydration script — 用量数据由服务端注入到页面 HTML 的 <script> 中,无公开 API。",
|
||||||
|
"采集方式: CDP-driven — 通过 CDP proxy (/eval) 在 Chrome tab 内执行 fetch(location.href, {credentials:'include'}),浏览器自动带 httpOnly cookies 获取 SSR HTML,正则解析 rollingUsage/weeklyUsage/monthlyUsage 的 usagePercent + resetInSec。",
|
||||||
|
"数据更新: 采集脚本每 5 分钟跑一次 (--daemon 模式) 或仪表板手动点击 Refresh 触发按需采集 (collect_now)。",
|
||||||
|
"告警: 任一账号任一指标 usagePercent > 80% 时卡片标橙;> 95% 时标红并提示考虑切号。",
|
||||||
|
"架构关键: Chrome 单 profile 同时只能登录 1 个 opencode 账号 → 每次采集只更新当前登录账号的数据,其他账号保留上次采集值。用户切换账号后触发 Refresh 即可更新新账号数据。"
|
||||||
|
],
|
||||||
|
"participants": [
|
||||||
|
{"name": "老莫", "device": "Windows 192.168.1.16", "role": "4 个 opencode 账号的持有者 + Chrome 登录态提供方 + dashboard 使用者", "note": "运行 usage_collector.py + xmpp_bot /usage endpoint (5807) + CDP proxy (3456)"},
|
||||||
|
{"name": "莫荷", "device": "Linux 192.168.1.246", "role": "dashboard 服务 (5803) 代理 /api/usage 到 Windows xmpp_bot", "note": "通过 _bridge_post(/usage) 代理到 Windows:5807"},
|
||||||
|
{"name": "Aliyun 47.115.32.206", "device": "Aliyun 公网服务器", "role": "Dashboard 前端访问入口 (ags.yoin.fun via nginx → 5803)", "note": "公网入口"}
|
||||||
|
],
|
||||||
|
"usage": [
|
||||||
|
"1. 首次配置: 逐个登录 4 个 opencode 账号到 Windows Chrome → 从 URL 获取 workspace ID (wrk_XXX) 填入 accounts.json → 触发 Refresh 采集",
|
||||||
|
"2. 采集脚本自动每 5min 运行 (--daemon),或仪表板点击 [Refresh Now] 按钮触发按需采集 (collect_now)",
|
||||||
|
"3. 仪表板显示 4 张卡片,每张卡片含 5h / weekly / monthly 三个进度条 + 倒计时",
|
||||||
|
"4. 当用量超 80% 卡片变橙,超 95% 变红 + 告警文字",
|
||||||
|
"5. 当某账号 session 过期 (Chrome tab 跳转到 auth.opencode.ai/authorize),卡片显示 'session expired — 请重新登录' 提示",
|
||||||
|
"6. 切换账号采集: 在 Chrome 中登录另一个 opencode 账号 → 点击 Refresh → 新账号数据更新,旧账号保留上次值"
|
||||||
|
],
|
||||||
|
"troubleshooting": [
|
||||||
|
"如果卡片显示 'session expired': 在 Windows Chrome 中重新登录该 opencode 账号,然后点击 Refresh",
|
||||||
|
"如果所有账号都显示过期: Chrome 可能已关闭或 CDP proxy (3456) 未运行 — 检查 Chrome 进程和 cdp-proxy.mjs",
|
||||||
|
"如果 Refresh 按钮无响应: 检查 xmpp_bot (Windows:5807) 是否 LISTENING + dashboard (246:5803) 的 _bridge_post 是否能联通 Windows",
|
||||||
|
"如果某账号 usagePercent 为 null 且无过期提示: 可能该账号没有 Go 订阅,或 SSR hydration 脚本结构变更 — 需手动在 Chrome 中打开页面验证",
|
||||||
|
"采集数据过旧 (更新时间 > 10min): 检查 usage_collector.py --daemon 是否在运行,或 CDP proxy 是否正常",
|
||||||
|
"CDP proxy 不可达: 确认 Chrome 以 --remote-debugging-port=9222 启动 + cdp-proxy.mjs (localhost:3456) 在运行"
|
||||||
|
],
|
||||||
|
"related": "依赖 xmpp_bot HTTP bridge (5807) — 与 EasyTier/RDP 共享同一 bridge 端口; 依赖 CDP proxy (3456) — 与 web-access skill 共享"
|
||||||
|
},
|
||||||
|
|
||||||
|
"ai_spec": {
|
||||||
|
"apis": [
|
||||||
|
{"method": "POST", "path": "/usage", "body": "{\"action\":\"status\"}", "returns": "{ok, accounts: [{key_id, workspace_id, label, rolling, weekly, monthly, last_update, session_expired, error}], last_refresh, collected_key_id}", "note": "读取缓存,即时返回"},
|
||||||
|
{"method": "POST", "path": "/usage", "body": "{\"action\":\"collect_now\"}", "returns": "{ok, message, triggered_at}", "note": "触发后台采集,~10-15s后 poll status 看结果"},
|
||||||
|
{"method": "GET", "path": "/api/usage", "returns": "同 /usage action=status", "proxied_to": "xmpp_bot /usage action=status via _bridge_post"},
|
||||||
|
{"method": "POST", "path": "/api/usage/refresh", "body": "{}", "returns": "同 /usage action=collect_now", "proxied_to": "xmpp_bot /usage action=collect_now via _bridge_post"}
|
||||||
|
],
|
||||||
|
"dependencies": [
|
||||||
|
"xmpp_bot on Windows 192.168.1.16:5807 — /usage HTTP endpoint (POST, 触发采集+读取缓存)",
|
||||||
|
"_bridge_post() + _BRIDGE_KEY in dashboard.py — proxy 机制同 EasyTier/RDP",
|
||||||
|
"gateway/scripts/usage_collector.py (Windows) — Python CDP-driven 采集脚本",
|
||||||
|
"gateway/scripts/usage_monitor/accounts.json — workspace ID × key_id × label 映射 (配置文件, .gitignore)",
|
||||||
|
"CDP proxy (cdp-proxy.mjs) localhost:3456 — Chrome 远程调试代理 (/eval, /targets, /navigate)",
|
||||||
|
"Chrome (Windows) with --remote-debugging-port=9222 — 单 profile, 当前登录 1 个 opencode 账号",
|
||||||
|
"Python 3.10+ (stdlib only: urllib, json, re, logging) — 无第三方依赖"
|
||||||
|
],
|
||||||
|
"architecture": {
|
||||||
|
"flow": "Dashboard(246:5803) → _bridge_post() → xmpp_bot(Windows:5807) → 触发/读取 usage_collector.py 采集结果",
|
||||||
|
"collection_mechanism": "usage_collector.py: 1) CDP /targets 查找 opencode.ai workspace tab → 2) CDP /eval 执行 JS: fetch(location.href, {credentials:'include'}) → 浏览器自动带 httpOnly cookies → 3) 从 SSR HTML 正则匹配 rollingUsage:$R[N]={status,usagePercent,resetInSec} (per-metric regex, 处理嵌套 $R[N]={...}) → 4) 合并已有缓存(保留其他账号上次值) → 5) 写入 temp/usage_stats.json",
|
||||||
|
"pipeline": "CDP /eval + fetch → SSR HTML → 正则解析 → 合并缓存 → temp/usage_stats.json → xmpp_bot 读取缓存 → _bridge_post 代理回 dashboard → HTML 卡片渲染",
|
||||||
|
"participants": {
|
||||||
|
"windows_192_168_1_16": {"role": "采集执行 + CDP proxy + xmpp_bot /usage 端点 (5807)", "agent": "xxm"},
|
||||||
|
"linux_246": {"role": "Dashboard 服务 (5803) 代理 /api/usage 到 Windows xmpp_bot", "agent": "mohe"},
|
||||||
|
"opencode_ai_servers": {"role": "数据源; SSR hydration 注入了 lite.subscription.get 资源到页面 HTML <script>; tab 跳转到 auth.opencode.ai/authorize 表示 session 失效"}
|
||||||
|
},
|
||||||
|
"cdp_driven_mechanism": {
|
||||||
|
"key_insight": "httpOnly cookies 无法通过 document.cookie 提取,但 fetch(location.href, {credentials:'include'}) 从浏览器内部发起请求时会自动带上所有 cookies (含 httpOnly) → 无需提取/存储 cookies",
|
||||||
|
"collection_flow": "CDP /targets → 找 opencode.ai tab → CDP /eval → JS fetch(location.href, {credentials:'include'}) → 获取 SSR HTML → 正则解析 usagePercent + resetInSec",
|
||||||
|
"single_account_limitation": "Chrome 单 profile 同时只能登录 1 个 opencode 账号 → 每次采集只更新当前登录账号; 其他账号保留上次值; 用户切换账号后 Refresh 即可采集新账号"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"constraints": [
|
||||||
|
"采集必须通过 CDP /eval + fetch 在 Chrome tab 内执行 — 不能用 Python requests + cookies (httpOnly cookies 无法提取)",
|
||||||
|
"采集脚本必须在 Windows 执行 (CDP proxy 连接本机 Chrome :9222)",
|
||||||
|
"Chrome 必须以 --remote-debugging-port=9222 启动 + cdp-proxy.mjs (3456) 必须运行",
|
||||||
|
"Workspace ID 必须从已登录的 opencode.ai 页面 URL 提取 — 不要手动猜测",
|
||||||
|
"解析 SSR 时用 per-metric regex (rollingUsage:.*?\\{[^}]*\\}) 而非 blob regex — SSR 格式是嵌套 $R[N]={...}, blob regex 在遇到内层 } 时会断",
|
||||||
|
"采集频率: 默认 5min — 过密会触发 opencode.ai 风控",
|
||||||
|
"session_expired 判定: Chrome tab URL 包含 'auth.opencode.ai' 或 'auth/authorize' → session 失效",
|
||||||
|
"accounts.json 不可提交 git — 已加入 .gitignore (含 workspace IDs)"
|
||||||
|
],
|
||||||
|
"must_not": [
|
||||||
|
"不要把 accounts.json 提交到 git — 已加入 .gitignore",
|
||||||
|
"不要在采集脚本中硬编码 workspace ID — 必须从 accounts.json 配置读取",
|
||||||
|
"不要尝试用 Python requests + 提取的 cookies 采集 — httpOnly cookies 无法通过 document.cookie 或 CDP /eval 提取",
|
||||||
|
"不要用 blob regex 解析 SSR — 嵌套 $R[N]={...} 会导致 [^}]* 在内层 } 处断; 用 per-metric regex 替代",
|
||||||
|
"不要重写整个 fI() 函数 在 dashboard.html — 用 create-once/update-state pattern 像 EasyTier/RDP 那样添加 usage section",
|
||||||
|
"不要在 CDP /eval 中返回整个 HTML (~16KB) — 尽量在 JS 中提取关键数据后返回小 JSON (当前实现返回完整 HTML, 可优化)"
|
||||||
|
],
|
||||||
|
"related_modules": [
|
||||||
|
{"module": "easytier", "relation": "共享 xmpp_bot HTTP bridge (5807); EasyTier VPN 不影响 usage 监控,但二者均通过 _bridge_post 代理触发"},
|
||||||
|
{"module": "rdp", "relation": "共享 xmpp_bot HTTP bridge (5807); 采集脚本运行在 Windows 同一机器上,但功能相互独立"}
|
||||||
|
],
|
||||||
|
"tests": [
|
||||||
|
{"id": "UM01", "name": "POST /usage action=status returns accounts array", "endpoint": "POST /usage {action:status} — 期望 response.accounts 是数组, 含 key_id/rolling/weekly/monthly 字段"},
|
||||||
|
{"id": "UM02", "name": "POST /usage action=collect_now triggers CDP collection", "endpoint": "POST /usage {action:collect_now} — 期望 response.ok=true, 几秒后 cache 文件更新"},
|
||||||
|
{"id": "UM03", "name": "Session expired account shows auth redirect message", "endpoint": "POST /usage — 当 tab 在 auth.opencode.ai 时, account.error 应含 'session expired'"},
|
||||||
|
{"id": "UM04", "name": "Usage cards render without flicker on consecutive polls", "endpoint": "连续 3 次 GET /api/usage — dashboard 卡片不应闪烁/recreate DOM"},
|
||||||
|
{"id": "UM05", "name": "High-usage warning displays when usagePercent > 80", "endpoint": "GET /api/usage — usagePercent > 80 卡片应标橙;> 95 标红"}
|
||||||
|
],
|
||||||
|
"known_issues": [
|
||||||
|
"opencode.ai Go 用量目前无公开 API — PR #16513 未上线,数据唯一来源是 SSR hydration script",
|
||||||
|
"Chrome 单 profile 限制: 一次只能登录一个 opencode 账号 → 每次采集只更新当前登录账号; 需用户手动切换账号",
|
||||||
|
"Session 有效期: opencode.ai session cookies 可能随时过期 → 采集脚本检测 tab URL 是否在 auth.opencode.ai 来判定 session_expired",
|
||||||
|
"CDP proxy 依赖: 若 Chrome 关闭或 cdp-proxy.mjs 未运行,采集将失败 — 需确保两个进程都在运行",
|
||||||
|
"HTML 返回大小: 当前 CDP /eval 返回完整 SSR HTML (~16KB), 未来可优化为 JS 端提取关键数据后返回小 JSON"
|
||||||
|
],
|
||||||
|
"related_files": [
|
||||||
|
"gateway/scripts/usage_collector.py — Python CDP-driven 采集脚本 (v2.0, 重写)",
|
||||||
|
"gateway/scripts/usage_monitor/accounts.json — workspace ID 配置 (.gitignore)",
|
||||||
|
"gateway/scripts/dashboard.py — /api/usage + /api/usage/refresh 端点 + _bridge_post 代理",
|
||||||
|
"gateway/scripts/templates/dashboard.html — fI() function 中添加 usage section (create-once pattern)",
|
||||||
|
"gateway/scripts/specs/usage_monitor.json — 本 spec 文件",
|
||||||
|
"xmpp_agent_core.py — /usage HTTP endpoint in _BridgeHandler.do_POST()",
|
||||||
|
"gateway/temp/usage_stats.json — 采集结果缓存文件 (.gitignore)",
|
||||||
|
".opencode/skills/web-access/scripts/cdp-proxy.mjs — CDP proxy (localhost:3456, 提供 /eval /targets /navigate)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,10 +224,109 @@ async function fI(){
|
|||||||
var fOn=e4.rdp_enabled&&e4.tunnel_running;
|
var fOn=e4.rdp_enabled&&e4.tunnel_running;
|
||||||
document.getElementById('btn-rdp-on').disabled=fOn;
|
document.getElementById('btn-rdp-on').disabled=fOn;
|
||||||
document.getElementById('btn-rdp-off').disabled=!fOn;
|
document.getElementById('btn-rdp-off').disabled=!fOn;
|
||||||
document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_enabled?'Tunnel pending':'Disconnected';
|
document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_enabled?'Tunnel pending':'Disconnected';
|
||||||
}catch(e4){}
|
}catch(e4){}
|
||||||
|
|
||||||
|
/* --- OpenCode Go Usage section: create once, update state each cycle (no flicker) --- */
|
||||||
|
var us=document.getElementById('usage-section');
|
||||||
|
if(!us){
|
||||||
|
us=document.createElement('div');us.id='usage-section';us.className='ps';us.style.marginTop='16px';
|
||||||
|
us.innerHTML='<h2>OpenCode Go Usage<span class="help-btn" onclick="showModuleHelp(\'usage_monitor\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'usage_monitor\',\'ai\')" title="AI Spec">§</span></h2>'
|
||||||
|
+'<div id="usage-cards" style="display:flex;flex-wrap:wrap;gap:10px;"></div>'
|
||||||
|
+'<div style="margin-top:8px;display:flex;gap:8px;align-items:center">'
|
||||||
|
+'<button class="btn s" id="btn-usage-refresh">Refresh Now</button>'
|
||||||
|
+'<span id="usage-last" style="font-size:12px;color:var(--dim)">-</span>'
|
||||||
|
+'</div>';
|
||||||
|
ci.appendChild(us);
|
||||||
|
document.getElementById('btn-usage-refresh').onclick=function(){
|
||||||
|
var btn=this;btn.disabled=true;
|
||||||
|
document.getElementById('usage-last').textContent='采集中…';
|
||||||
|
fetch('/api/usage/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})
|
||||||
|
.then(function(r){return r.json()})
|
||||||
|
.then(function(d){
|
||||||
|
if(d.ok){toast('采集已触发,10-15s 后自动刷新');}
|
||||||
|
else{toast('Refresh 失败: '+(d.error||'unknown'),'err');}
|
||||||
|
setTimeout(function(){btn.disabled=false;fI()},8000);
|
||||||
|
})
|
||||||
|
.catch(function(e){toast('Network error','err');btn.disabled=false;});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
var e5=await fetch('/api/usage').then(function(r){return r.json()});
|
||||||
|
var cards=document.getElementById('usage-cards');
|
||||||
|
var lu=document.getElementById('usage-last');
|
||||||
|
if(!e5||!e5.ok){
|
||||||
|
if(lu)lu.textContent='—'+(e5&&e5.error?' '+e5.error:'');
|
||||||
|
if(cards)cards.innerHTML='<div style="color:var(--dim);padding:8px">'+esc(e5&&e5.error||'未采集')+'</span>';
|
||||||
|
}else{
|
||||||
|
// Update last refresh time
|
||||||
|
if(lu){
|
||||||
|
var ts=e5.last_refresh_iso;
|
||||||
|
lu.textContent=ts?('上次采集: '+ts.replace('T',' ').replace(/\+00:00$/,' UTC')):'—';
|
||||||
|
}
|
||||||
|
// Render cards via innerHTML rebuild (simple data cards, no flicker risk)
|
||||||
|
var html='';
|
||||||
|
var accts=e5.accounts||[];
|
||||||
|
for(var i=0;i<accts.length;i++){
|
||||||
|
var a=accts[i];
|
||||||
|
var maxPct=0;
|
||||||
|
if(a.rolling&&a.rolling.usage_percent!=null)maxPct=Math.max(maxPct,a.rolling.usage_percent);
|
||||||
|
if(a.weekly&&a.weekly.usage_percent!=null)maxPct=Math.max(maxPct,a.weekly.usage_percent);
|
||||||
|
if(a.monthly&&a.monthly.usage_percent!=null)maxPct=Math.max(maxPct,a.monthly.usage_percent);
|
||||||
|
var edge='var(--border)';
|
||||||
|
var barColor='var(--accent)';
|
||||||
|
if(maxPct>=95){edge='#e53935';barColor='#e53935';}
|
||||||
|
else if(maxPct>=80){edge='#fb8c00';barColor='#fb8c00';}
|
||||||
|
html+='<div style="background:var(--card);border:1px solid '+edge+';border-radius:8px;padding:10px 12px;min-width:200px;flex:1;max-width:260px">'
|
||||||
|
+'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">'
|
||||||
|
+'<strong style="font-size:14px">'+esc(a.label||a.key_id||a.workspace_id||'?')+'</strong>';
|
||||||
|
if(a.session_expired){
|
||||||
|
html+='<span style="font-size:11px;color:#e53935;background:rgba(229,57,53,.1);padding:2px 6px;border-radius:4px">SESSION EXPIRED</span>';
|
||||||
|
}else if(a.error){
|
||||||
|
html+='<span style="font-size:11px;color:var(--dim)" title="'+esc(a.error)+'">⚠</span>';
|
||||||
|
}else{
|
||||||
|
html+='<span style="font-size:11px;color:var(--dim)">'+maxPct+'% max</span>';
|
||||||
|
}
|
||||||
|
html+='</div>';
|
||||||
|
if(a.session_expired||a.error){
|
||||||
|
html+='<div style="font-size:12px;color:var(--dim);padding:4px 0">'+esc(a.error||'请重新登录此账号')+'</div>';
|
||||||
|
}else{
|
||||||
|
html+=_renderUsageMetric('5h rolling',a.rolling,barColor);
|
||||||
|
html+=_renderUsageMetric('Weekly',a.weekly,barColor);
|
||||||
|
html+=_renderUsageMetric('Monthly',a.monthly,barColor);
|
||||||
|
}
|
||||||
|
html+='</div>';
|
||||||
|
}
|
||||||
|
if(!accts.length){html='<div style="color:var(--dim);padding:8px">未配置账号 (accounts.json 未就绪,或 cookies 未提取)</div>';}
|
||||||
|
if(cards)cards.innerHTML=html;
|
||||||
|
}
|
||||||
|
}catch(e5){}
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
}
|
}
|
||||||
|
function _fmtCountdown(sec){
|
||||||
|
if(sec==null)return '—';
|
||||||
|
if(sec<=0)return '即将重置';
|
||||||
|
sec=Math.floor(sec);
|
||||||
|
var d=Math.floor(sec/86400),h=Math.floor((sec%86400)/3600),m=Math.floor((sec%3600)/60),s=sec%60;
|
||||||
|
if(d>0)return d+'d '+h+'h';
|
||||||
|
if(h>0)return h+'h '+m+'m';
|
||||||
|
if(m>0)return m+'m '+s+'s';
|
||||||
|
return s+'s';
|
||||||
|
}
|
||||||
|
function _renderUsageMetric(label,m,barColor){
|
||||||
|
if(!m||m.usage_percent==null){
|
||||||
|
return '<div style="margin-bottom:6px"><div style="font-size:11px;color:var(--dim);display:flex;justify-content:space-between"><span>'+esc(label)+'</span></div><div style="background:var(--bg);border-radius:4px;height:6px;margin-top:2px"></div><div style="font-size:10px;color:var(--dim);margin-top:1px">—</div></div>';
|
||||||
|
}
|
||||||
|
var pct=m.usage_percent;
|
||||||
|
var txt=pct+'% · '+_fmtCountdown(m.reset_in_sec);
|
||||||
|
return '<div style="margin-bottom:6px">'
|
||||||
|
+'<div style="font-size:11px;color:var(--dim);display:flex;justify-content:space-between"><span>'+esc(label)+'</span><span>'+(pct>=95?'CRITICAL':pct>=80?'WARNING':'OK')+'</span></div>'
|
||||||
|
+'<div style="background:var(--bg);border-radius:4px;height:6px;margin-top:2px;overflow:hidden">'
|
||||||
|
+'<div style="background:'+barColor+';height:100%;width:'+pct+'%;transition:width .3s"></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'<div style="font-size:10px;color:var(--dim);margin-top:1px">'+esc(txt)+'</div>'
|
||||||
|
+'</div>';
|
||||||
|
}
|
||||||
async function fH(){try{var r=await fetch('/api/prd'),d=await r.json();if(!d.ok||!d.content){fill('prd','Failed');return}var el=document.getElementById('ct-prd');var sp=el?el.querySelector('div')?el.querySelector('div').scrollTop:0:0;fill('prd','<div style=padding:8px 0>'+mdRender(d.content)+'</div>');var el2=document.getElementById('ct-prd');if(el2&&sp){setTimeout(function(){(el2.querySelector('div')||el2).scrollTop=sp},0)}}catch(e){fill('prd','Error')}}async function fTree(){try{var r=await fetch('/api/services'),d=await r.json();if(!d.services||!d.services.length){fill('tree','No data');return}
|
async function fH(){try{var r=await fetch('/api/prd'),d=await r.json();if(!d.ok||!d.content){fill('prd','Failed');return}var el=document.getElementById('ct-prd');var sp=el?el.querySelector('div')?el.querySelector('div').scrollTop:0:0;fill('prd','<div style=padding:8px 0>'+mdRender(d.content)+'</div>');var el2=document.getElementById('ct-prd');if(el2&&sp){setTimeout(function(){(el2.querySelector('div')||el2).scrollTop=sp},0)}}catch(e){fill('prd','Error')}}async function fTree(){try{var r=await fetch('/api/services'),d=await r.json();if(!d.services||!d.services.length){fill('tree','No data');return}
|
||||||
var tree={name:'System',children:[]};var map={};d.services.forEach(function(s){map[s.name]=s;if(!s.depends_on||!s.depends_on.length){tree.children.push(s)}else{s._parents=s.depends_on}});
|
var tree={name:'System',children:[]};var map={};d.services.forEach(function(s){map[s.name]=s;if(!s.depends_on||!s.depends_on.length){tree.children.push(s)}else{s._parents=s.depends_on}});
|
||||||
var h='<div style=font-size:19px;line-height:1.8>';
|
var h='<div style=font-size:19px;line-height:1.8>';
|
||||||
|
|||||||
@@ -0,0 +1,739 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
usage_collector.py — OpenCode Go 订阅用量采集脚本 (Hybrid v3.0)
|
||||||
|
================================================================
|
||||||
|
混合采集模式:
|
||||||
|
主模式 — HTTP + stored cookies (全部 4 账号同时采集)
|
||||||
|
辅助模式 — CDP (仅当前 Chrome 登录的账号,用于补充/验证)
|
||||||
|
|
||||||
|
采集流程:
|
||||||
|
1. 遍历 accounts.json 中所有配置了 workspace_id 的账号
|
||||||
|
2. 对每个账号:
|
||||||
|
a. 主模式: 读取 cookies/{key_id}.json → 构建 Cookie header →
|
||||||
|
HTTP GET https://opencode.ai/workspace/{ws_id}/go → 解析 SSR HTML
|
||||||
|
b. 辅助模式(仅对 Chrome 当前登录账号): CDP fetch(location.href) → 解析 SSR HTML
|
||||||
|
3. 合并结果到 gateway/temp/usage_stats.json
|
||||||
|
|
||||||
|
Cookie 提取流程 (一次性,每个账号登录后执行):
|
||||||
|
python extract_cookies.py key1 --workspace-id wrk_XXX
|
||||||
|
→ cookies/key1.json (含 httpOnly auth cookie)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python usage_collector.py # 一次性采集 (全部账号)
|
||||||
|
python usage_collector.py --daemon # 后台守护,每 5 分钟采集一次
|
||||||
|
python usage_collector.py --print # 采集 + 打印结果
|
||||||
|
python usage_collector.py --cdp-only # 仅 CDP 采集当前登录账号
|
||||||
|
|
||||||
|
相关文件:
|
||||||
|
- usage_monitor/accounts.json — 账号配置
|
||||||
|
- usage_monitor/cookies/{key_id}.json — 存储的 cookies (gitignored)
|
||||||
|
- usage_monitor/extract_cookies.py — Cookie 提取工具
|
||||||
|
- gateway/temp/usage_stats.json — 采集结果缓存
|
||||||
|
- xmpp_agent_core.py /usage endpoint 触发本脚本
|
||||||
|
- CDP proxy (cdp-proxy.mjs) localhost:3456
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import argparse
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import http.cookiejar
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# ── Path bootstrap (platform-agnostic) ──
|
||||||
|
_SCRIPT_DIR = Path(__file__).resolve().parent # gateway/scripts/
|
||||||
|
_GATEWAY_DIR = _SCRIPT_DIR.parent # gateway/
|
||||||
|
_PROJECT_DIR = _GATEWAY_DIR.parent # AgentsMeeting/
|
||||||
|
|
||||||
|
USAGE_MONITOR_DIR = _SCRIPT_DIR / "usage_monitor"
|
||||||
|
ACCOUNTS_FILE = USAGE_MONITOR_DIR / "accounts.json"
|
||||||
|
COOKIES_DIR = USAGE_MONITOR_DIR / "cookies"
|
||||||
|
TEMP_DIR = _GATEWAY_DIR / "temp"
|
||||||
|
OUTPUT_FILE = TEMP_DIR / "usage_stats.json"
|
||||||
|
LOG_FILE = _GATEWAY_DIR / "logs" / "usage_collector.log"
|
||||||
|
|
||||||
|
# ── Logging setup ──
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [usage] %(levelname)s: %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(str(LOG_FILE), encoding='utf-8'),
|
||||||
|
logging.StreamHandler(sys.stdout)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
log = logging.getLogger('usage_collector')
|
||||||
|
|
||||||
|
# ── Constants ──
|
||||||
|
DEFAULT_POLL_INTERVAL_SEC = 300 # 5 minutes
|
||||||
|
CDP_PROXY_URL = os.environ.get('CDP_PROXY_URL', 'http://localhost:3456')
|
||||||
|
CDP_EVAL_TIMEOUT = 20
|
||||||
|
CDP_TARGETS_TIMEOUT = 5
|
||||||
|
HTTP_TIMEOUT = 15
|
||||||
|
OPENCODE_BASE_URL = 'https://opencode.ai'
|
||||||
|
|
||||||
|
# ── SSR HTML parsing ──
|
||||||
|
#
|
||||||
|
# opencode.ai SSR hydration format:
|
||||||
|
# lite.subscription.get["wrk_XXX"]).p.v = $R[N] = {
|
||||||
|
# rollingUsage: $R[M] = {status:"ok", resetInSec:16602, usagePercent:37},
|
||||||
|
# weeklyUsage: $R[M] = {status:"ok", resetInSec:402661, usagePercent:15},
|
||||||
|
# monthlyUsage: $R[M] = {status:"ok", resetInSec:2674309, usagePercent:7}
|
||||||
|
# }
|
||||||
|
|
||||||
|
_METRIC_RE = re.compile(
|
||||||
|
r'(?P<metric>rollingUsage|weeklyUsage|monthlyUsage)'
|
||||||
|
r'\s*:\s*'
|
||||||
|
r'(?:\$R\[\d+\]\s*=\s*)?'
|
||||||
|
r'\{(?P<inner>[^}]*)\}',
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
_WORKSPACE_LITERAL_RE = re.compile(r'(?P<workspace>wrk_[A-Z0-9]{20,})')
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs():
|
||||||
|
"""Make sure all expected directories exist."""
|
||||||
|
USAGE_MONITOR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
COOKIES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_accounts_config():
|
||||||
|
"""Load accounts.json."""
|
||||||
|
if not ACCOUNTS_FILE.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(str(ACCOUNTS_FILE), 'r', encoding='utf-8-sig') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'Failed to read accounts.json: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# PRIMARY MODE: HTTP + Stored Cookies
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def load_stored_cookies(key_id):
|
||||||
|
"""
|
||||||
|
Load cookies from cookies/{key_id}.json.
|
||||||
|
Returns list of cookie dicts or None if file doesn't exist.
|
||||||
|
"""
|
||||||
|
cookie_file = COOKIES_DIR / f'{key_id}.json'
|
||||||
|
if not cookie_file.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(str(cookie_file), 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get('cookies', [])
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'Failed to load cookies for {key_id}: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_dict_to_jar(cookies_list, workspace_id=None):
|
||||||
|
"""Convert stored cookie dicts → http.cookiejar.CookieJar."""
|
||||||
|
jar = http.cookiejar.CookieJar()
|
||||||
|
for c in cookies_list:
|
||||||
|
domain = c.get('domain', '')
|
||||||
|
try:
|
||||||
|
cookie = http.cookiejar.Cookie(
|
||||||
|
version=0, name=c.get('name', ''), value=c.get('value', ''),
|
||||||
|
port=None, port_specified=False,
|
||||||
|
domain=domain, domain_specified=bool(domain),
|
||||||
|
domain_initial_dot=domain.startswith('.'),
|
||||||
|
path=c.get('path', '/'), path_specified=True,
|
||||||
|
secure=c.get('secure', False), expires=c.get('expires'),
|
||||||
|
discard=False, comment=None, comment_url=None,
|
||||||
|
rest={'HttpOnly': None} if c.get('httpOnly') else {},
|
||||||
|
rfc2109=False,
|
||||||
|
)
|
||||||
|
jar.set_cookie(cookie)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f'Failed to convert cookie {c.get("name","?")}: {e}')
|
||||||
|
return jar
|
||||||
|
|
||||||
|
|
||||||
|
def _jar_to_cookie_dicts(jar):
|
||||||
|
"""Convert CookieJar → list of cookie dicts for JSON storage."""
|
||||||
|
out = []
|
||||||
|
for c in jar:
|
||||||
|
out.append({
|
||||||
|
'name': c.name, 'value': c.value,
|
||||||
|
'domain': c.domain, 'path': c.path,
|
||||||
|
'secure': c.secure, 'expires': c.expires,
|
||||||
|
'httpOnly': 'HttpOnly' in (c._rest or {}),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _save_cookies_back(key_id, jar, workspace_id):
|
||||||
|
"""Save updated cookies from jar back to cookies/{key_id}.json."""
|
||||||
|
cookies_list = _jar_to_cookie_dicts(jar)
|
||||||
|
if not cookies_list:
|
||||||
|
return
|
||||||
|
cookie_file = COOKIES_DIR / f'{key_id}.json'
|
||||||
|
data = {
|
||||||
|
'key_id': key_id, 'workspace_id': workspace_id,
|
||||||
|
'extracted_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||||
|
'cookie_count': len(cookies_list), 'cookies': cookies_list,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(str(cookie_file), 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
log.info(f'[keepalive] Updated cookies for {key_id} ({len(cookies_list)} cookies)')
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'[keepalive] Failed to save cookies for {key_id}: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_ssr_via_http(workspace_id, cookies_list, key_id):
|
||||||
|
"""
|
||||||
|
HTTP GET with CookieJar (auto-captures Set-Cookie for keepalive).
|
||||||
|
Returns (html_text, error_message, updated_jar).
|
||||||
|
"""
|
||||||
|
url = f'{OPENCODE_BASE_URL}/workspace/{workspace_id}/go'
|
||||||
|
jar = _cookie_dict_to_jar(cookies_list, workspace_id)
|
||||||
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||||
|
req = urllib.request.Request(url, method='GET')
|
||||||
|
req.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')
|
||||||
|
req.add_header('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8')
|
||||||
|
|
||||||
|
try:
|
||||||
|
with opener.open(req, timeout=HTTP_TIMEOUT) as resp:
|
||||||
|
status = resp.getcode()
|
||||||
|
final_url = resp.geturl()
|
||||||
|
|
||||||
|
if 'auth.opencode.ai' in final_url or 'auth/authorize' in final_url:
|
||||||
|
return None, 'session expired (redirected to auth.opencode.ai)', jar
|
||||||
|
|
||||||
|
if status != 200:
|
||||||
|
return None, f'HTTP {status}', jar
|
||||||
|
|
||||||
|
html = resp.read().decode('utf-8', errors='replace')
|
||||||
|
return html, None, jar
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return None, f'HTTP {e.code}: {e.reason}', jar
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return None, f'URL error: {e.reason}', jar
|
||||||
|
except Exception as e:
|
||||||
|
return None, f'Unexpected: {e}', jar
|
||||||
|
|
||||||
|
|
||||||
|
def _cookies_changed(original, jar):
|
||||||
|
"""Check if jar cookies differ from original list (Set-Cookie was received)."""
|
||||||
|
jar_cookies = _jar_to_cookie_dicts(jar)
|
||||||
|
if len(jar_cookies) != len(original):
|
||||||
|
return True
|
||||||
|
for a, b in zip(jar_cookies, original):
|
||||||
|
if a.get('value') != b.get('value') or a.get('expires') != b.get('expires'):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def collect_via_http(key_id, account_cfg):
|
||||||
|
"""
|
||||||
|
Collect usage data for one account via HTTP + stored cookies.
|
||||||
|
Captures Set-Cookie for keepalive (saves refreshed cookies back to JSON).
|
||||||
|
"""
|
||||||
|
workspace_id = account_cfg.get('workspace_id', '').strip()
|
||||||
|
if not workspace_id:
|
||||||
|
return {
|
||||||
|
'key_id': key_id, 'label': account_cfg.get('label', key_id),
|
||||||
|
'workspace_id': None, 'error': 'no workspace_id configured',
|
||||||
|
'session_expired': False,
|
||||||
|
'last_update_iso': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies = load_stored_cookies(key_id)
|
||||||
|
if not cookies:
|
||||||
|
return {
|
||||||
|
'key_id': key_id, 'label': account_cfg.get('label', key_id),
|
||||||
|
'workspace_id': workspace_id,
|
||||||
|
'error': 'no stored cookies — run extract_cookies.py after logging in',
|
||||||
|
'session_expired': False,
|
||||||
|
'last_update_iso': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(f'[HTTP] Fetching {workspace_id}/go for {key_id}...')
|
||||||
|
|
||||||
|
html, err, jar = fetch_ssr_via_http(workspace_id, cookies, key_id)
|
||||||
|
|
||||||
|
# Cookie keepalive: if server sent Set-Cookie, save refreshed cookies
|
||||||
|
if _cookies_changed(cookies, jar):
|
||||||
|
_save_cookies_back(key_id, jar, workspace_id)
|
||||||
|
|
||||||
|
if err:
|
||||||
|
session_expired = 'auth' in err.lower() or 'session' in err.lower()
|
||||||
|
log.warning(f'[HTTP] {key_id} failed: {err}')
|
||||||
|
return {
|
||||||
|
'key_id': key_id, 'label': account_cfg.get('label', key_id),
|
||||||
|
'workspace_id': workspace_id, 'error': err,
|
||||||
|
'session_expired': session_expired,
|
||||||
|
'last_update_iso': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed = parse_usage_from_html(html, workspace_id)
|
||||||
|
if not parsed:
|
||||||
|
return {
|
||||||
|
'key_id': key_id, 'label': account_cfg.get('label', key_id),
|
||||||
|
'workspace_id': workspace_id,
|
||||||
|
'error': 'no usage metrics in SSR HTML',
|
||||||
|
'session_expired': False,
|
||||||
|
'last_update_iso': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'key_id': key_id, 'label': account_cfg.get('label', key_id),
|
||||||
|
'workspace_id': parsed['workspace_id'],
|
||||||
|
'rolling': parsed['rolling'], 'weekly': parsed['weekly'],
|
||||||
|
'monthly': parsed['monthly'], 'session_expired': False,
|
||||||
|
'last_update_iso': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||||
|
'error': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(f'[HTTP] SUCCESS {key_id}: '
|
||||||
|
f'rolling={result["rolling"]["usage_percent"]}% '
|
||||||
|
f'weekly={result["weekly"]["usage_percent"]}% '
|
||||||
|
f'monthly={result["monthly"]["usage_percent"]}%')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# BONUS MODE: CDP (currently logged-in account only)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def cdp_get_targets():
|
||||||
|
"""Query CDP proxy for Chrome targets."""
|
||||||
|
url = f'{CDP_PROXY_URL}/targets'
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, method='GET')
|
||||||
|
with urllib.request.urlopen(req, timeout=CDP_TARGETS_TIMEOUT) as resp:
|
||||||
|
data = json.loads(resp.read().decode('utf-8'))
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data.get('targets', data.get('result', []))
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'CDP /targets error: {e}')
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def cdp_eval(target_id, js_code):
|
||||||
|
"""Execute JS in a Chrome tab via CDP proxy."""
|
||||||
|
url = f'{CDP_PROXY_URL}/eval?target={target_id}'
|
||||||
|
data = js_code.encode('utf-8')
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, data=data, method='POST')
|
||||||
|
with urllib.request.urlopen(req, timeout=CDP_EVAL_TIMEOUT) as resp:
|
||||||
|
return json.loads(resp.read().decode('utf-8'))
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'CDP /eval error: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cdp_navigate(target_id, url):
|
||||||
|
"""Navigate a Chrome tab to a URL via CDP proxy."""
|
||||||
|
full_url = f'{CDP_PROXY_URL}/navigate?target={target_id}&url={urllib.parse.quote(url, safe="")}'
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(full_url, method='GET')
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
return json.loads(resp.read().decode('utf-8'))
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'CDP /navigate error: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_opencode_tab():
|
||||||
|
"""Find an opencode.ai workspace tab in Chrome."""
|
||||||
|
targets = cdp_get_targets()
|
||||||
|
if not targets:
|
||||||
|
log.warning('No Chrome targets — CDP proxy may be down or Chrome not running')
|
||||||
|
return None
|
||||||
|
|
||||||
|
for t in targets:
|
||||||
|
url = t.get('url', '')
|
||||||
|
if t.get('type') == 'page' and 'opencode.ai/workspace/' in url:
|
||||||
|
return t
|
||||||
|
for t in targets:
|
||||||
|
url = t.get('url', '')
|
||||||
|
if t.get('type') == 'page' and 'opencode.ai' in url:
|
||||||
|
return t
|
||||||
|
for t in targets:
|
||||||
|
if t.get('type') == 'page':
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_ssr_html_via_cdp(target_id, workspace_url=None):
|
||||||
|
"""Use CDP /eval to run fetch(location.href) inside Chrome tab."""
|
||||||
|
if workspace_url:
|
||||||
|
log.info(f'[CDP] Navigating to {workspace_url}')
|
||||||
|
cdp_navigate(target_id, workspace_url)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
js = """
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
var r = await fetch(location.href, {credentials:'include'});
|
||||||
|
var html = await r.text();
|
||||||
|
var wsMatch = html.match(/wrk_[A-Z0-9]{20,}/);
|
||||||
|
var wsId = wsMatch ? wsMatch[0] : null;
|
||||||
|
return JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
status: r.status,
|
||||||
|
url: location.href,
|
||||||
|
html: html,
|
||||||
|
workspace_id: wsId
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
return JSON.stringify({ok: false, error: e.message});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
result = cdp_eval(target_id, js)
|
||||||
|
if not result:
|
||||||
|
return None, 'CDP /eval returned no result'
|
||||||
|
|
||||||
|
value = None
|
||||||
|
if isinstance(result, dict):
|
||||||
|
value = result.get('value') or result.get('result')
|
||||||
|
elif isinstance(result, str):
|
||||||
|
value = result
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
return None, f'CDP /eval returned empty: {result}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
|
return None, f'Failed to parse /eval return: {e}'
|
||||||
|
|
||||||
|
if not parsed.get('ok'):
|
||||||
|
return None, f'JS fetch failed: {parsed.get("error", "unknown")}'
|
||||||
|
|
||||||
|
html = parsed.get('html', '')
|
||||||
|
ws_id = parsed.get('workspace_id')
|
||||||
|
status = parsed.get('status', 0)
|
||||||
|
url = parsed.get('url', '')
|
||||||
|
|
||||||
|
log.info(f'[CDP] fetch: status={status} url={url[:120]} html_len={len(html)} ws_id={ws_id}')
|
||||||
|
|
||||||
|
if 'auth.opencode.ai' in url or 'auth/authorize' in url:
|
||||||
|
return None, 'session expired (CDP tab on auth page)'
|
||||||
|
if status != 200 or not html:
|
||||||
|
return None, f'CDP fetch status={status} html_len={len(html)}'
|
||||||
|
return ws_id, html
|
||||||
|
|
||||||
|
|
||||||
|
def collect_via_cdp(accounts_cfg):
|
||||||
|
"""
|
||||||
|
Collect usage for the currently logged-in Chrome account via CDP.
|
||||||
|
Returns (key_id, result_dict) or (None, error_dict).
|
||||||
|
"""
|
||||||
|
tab = find_opencode_tab()
|
||||||
|
if not tab:
|
||||||
|
return None, {
|
||||||
|
'error': 'no Chrome tab (CDP proxy down or Chrome not running)',
|
||||||
|
'session_expired': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
target_id = tab.get('targetId') or tab.get('id') or ''
|
||||||
|
if not target_id:
|
||||||
|
return None, {'error': 'tab has no targetId', 'session_expired': False}
|
||||||
|
|
||||||
|
tab_url = tab.get('url', '')
|
||||||
|
|
||||||
|
# If on auth page, try navigating to a workspace URL
|
||||||
|
workspace_url = None
|
||||||
|
if 'auth.opencode.ai' in tab_url or 'auth/authorize' in tab_url:
|
||||||
|
if accounts_cfg:
|
||||||
|
for acc in accounts_cfg.get('accounts', []):
|
||||||
|
ws_id = acc.get('workspace_id', '').strip()
|
||||||
|
if ws_id:
|
||||||
|
workspace_url = f'{OPENCODE_BASE_URL}/workspace/{ws_id}/go'
|
||||||
|
break
|
||||||
|
if not workspace_url:
|
||||||
|
return None, {'error': 'session expired + no workspace URLs', 'session_expired': True}
|
||||||
|
elif 'opencode.ai/workspace/' in tab_url:
|
||||||
|
workspace_url = None # fetch(location.href) will work
|
||||||
|
|
||||||
|
ws_id, err_or_html = fetch_ssr_html_via_cdp(target_id, workspace_url)
|
||||||
|
if not ws_id:
|
||||||
|
error_msg = err_or_html or 'unknown fetch error'
|
||||||
|
return None, {
|
||||||
|
'error': error_msg,
|
||||||
|
'session_expired': 'session' in error_msg.lower() or 'auth' in error_msg.lower(),
|
||||||
|
}
|
||||||
|
|
||||||
|
html_text = err_or_html
|
||||||
|
parsed = parse_usage_from_html(html_text, ws_id)
|
||||||
|
if not parsed:
|
||||||
|
return ws_id, {
|
||||||
|
'workspace_id': ws_id,
|
||||||
|
'error': 'no usage metrics (no Go subscription?)',
|
||||||
|
'session_expired': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Match to account
|
||||||
|
label = ws_id
|
||||||
|
key_id = ws_id
|
||||||
|
if accounts_cfg:
|
||||||
|
for acc in accounts_cfg.get('accounts', []):
|
||||||
|
if acc.get('workspace_id') == ws_id:
|
||||||
|
label = acc.get('label', ws_id)
|
||||||
|
key_id = acc.get('key_id', ws_id)
|
||||||
|
break
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'key_id': key_id,
|
||||||
|
'label': label,
|
||||||
|
'workspace_id': parsed['workspace_id'],
|
||||||
|
'rolling': parsed['rolling'],
|
||||||
|
'weekly': parsed['weekly'],
|
||||||
|
'monthly': parsed['monthly'],
|
||||||
|
'session_expired': False,
|
||||||
|
'last_update_iso': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||||
|
'error': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(f'[CDP] SUCCESS {key_id}: '
|
||||||
|
f'rolling={result["rolling"]["usage_percent"]}% '
|
||||||
|
f'weekly={result["weekly"]["usage_percent"]}% '
|
||||||
|
f'monthly={result["monthly"]["usage_percent"]}%')
|
||||||
|
|
||||||
|
return key_id, result
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# SSR Parsing (shared by both modes)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def parse_usage_from_html(html_text, workspace_id_hint):
|
||||||
|
"""
|
||||||
|
Parse SSR HTML for usage data using per-metric regex.
|
||||||
|
Returns dict with workspace_id, rolling, weekly, monthly or None.
|
||||||
|
"""
|
||||||
|
metrics = {}
|
||||||
|
for m in _METRIC_RE.finditer(html_text):
|
||||||
|
metric_name = m.group('metric')
|
||||||
|
if metric_name in metrics:
|
||||||
|
continue
|
||||||
|
inner = m.group('inner')
|
||||||
|
status_match = re.search(r'status\s*:\s*"([^"]*)"', inner)
|
||||||
|
pct_match = re.search(r'usagePercent\s*:\s*(\d+)', inner)
|
||||||
|
reset_match = re.search(r'resetInSec\s*:\s*(\d+)', inner)
|
||||||
|
metrics[metric_name] = {
|
||||||
|
'usage_percent': int(pct_match.group(1)) if pct_match else None,
|
||||||
|
'reset_in_sec': int(reset_match.group(1)) if reset_match else None,
|
||||||
|
'status': status_match.group(1) if status_match else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
ws_match = _WORKSPACE_LITERAL_RE.search(html_text)
|
||||||
|
workspace_id = ws_match.group('workspace') if ws_match else workspace_id_hint
|
||||||
|
|
||||||
|
if not metrics:
|
||||||
|
return None
|
||||||
|
|
||||||
|
log.info(f'Parsed {workspace_id}: '
|
||||||
|
f'rolling={metrics.get("rollingUsage", {}).get("usage_percent")}% '
|
||||||
|
f'weekly={metrics.get("weeklyUsage", {}).get("usage_percent")}% '
|
||||||
|
f'monthly={metrics.get("monthlyUsage", {}).get("usage_percent")}%')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'workspace_id': workspace_id,
|
||||||
|
'rolling': metrics.get('rollingUsage', {'usage_percent': None, 'reset_in_sec': None, 'status': None}),
|
||||||
|
'weekly': metrics.get('weeklyUsage', {'usage_percent': None, 'reset_in_sec': None, 'status': None}),
|
||||||
|
'monthly': metrics.get('monthlyUsage', {'usage_percent': None, 'reset_in_sec': None, 'status': None}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Cache merge logic
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def load_existing_cache():
|
||||||
|
"""Load existing usage_stats.json or return empty structure."""
|
||||||
|
if not OUTPUT_FILE.exists():
|
||||||
|
return {'accounts': {}}
|
||||||
|
try:
|
||||||
|
with open(str(OUTPUT_FILE), 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if 'accounts' in data and isinstance(data['accounts'], list):
|
||||||
|
accounts_dict = {}
|
||||||
|
for acc in data['accounts']:
|
||||||
|
kid = acc.get('key_id', acc.get('workspace_id', '?'))
|
||||||
|
accounts_dict[kid] = acc
|
||||||
|
data['accounts'] = accounts_dict
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
return {'accounts': {}}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_result_into_cache(cache, key_id, new_result):
|
||||||
|
"""Update cache with new result for one account."""
|
||||||
|
if 'accounts' not in cache or not isinstance(cache['accounts'], dict):
|
||||||
|
cache['accounts'] = {}
|
||||||
|
cache['accounts'][key_id] = new_result
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Main collection (Hybrid)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def collect_all(cdp_only=False):
|
||||||
|
"""
|
||||||
|
Hybrid collection: HTTP for all accounts + CDP bonus for current login.
|
||||||
|
"""
|
||||||
|
ensure_dirs()
|
||||||
|
accounts_cfg = load_accounts_config()
|
||||||
|
if not accounts_cfg:
|
||||||
|
log.error('No accounts.json found')
|
||||||
|
return {'ok': False, 'error': 'no accounts.json', 'accounts': []}
|
||||||
|
|
||||||
|
cache = load_existing_cache()
|
||||||
|
collected_count = 0
|
||||||
|
cdp_key_id = None
|
||||||
|
|
||||||
|
# ── PRIMARY: HTTP collection for all accounts ──
|
||||||
|
if not cdp_only:
|
||||||
|
for acc_cfg in accounts_cfg.get('accounts', []):
|
||||||
|
key_id = acc_cfg.get('key_id', '')
|
||||||
|
if not key_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = collect_via_http(key_id, acc_cfg)
|
||||||
|
merge_result_into_cache(cache, key_id, result)
|
||||||
|
|
||||||
|
if result.get('error') is None:
|
||||||
|
collected_count += 1
|
||||||
|
elif result.get('session_expired'):
|
||||||
|
log.warning(f'{key_id} session expired — needs re-login + re-extract cookies')
|
||||||
|
|
||||||
|
# ── BONUS: CDP collection for currently logged-in account ──
|
||||||
|
try:
|
||||||
|
cdp_kid, cdp_result = collect_via_cdp(accounts_cfg)
|
||||||
|
if cdp_kid:
|
||||||
|
cdp_key_id = cdp_kid
|
||||||
|
# CDP result overrides HTTP for this account (more reliable — live cookies)
|
||||||
|
merge_result_into_cache(cache, cdp_kid, cdp_result)
|
||||||
|
if cdp_result.get('error') is None:
|
||||||
|
collected_count += 1
|
||||||
|
log.info(f'CDP override for {cdp_kid}')
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f'CDP bonus collection skipped: {e}')
|
||||||
|
|
||||||
|
# ── Build output ──
|
||||||
|
accounts_list = []
|
||||||
|
for acc_cfg in accounts_cfg.get('accounts', []):
|
||||||
|
kid = acc_cfg.get('key_id', acc_cfg.get('workspace_id', '?'))
|
||||||
|
if kid in cache.get('accounts', {}):
|
||||||
|
# Merge in account config fields
|
||||||
|
cached = cache['accounts'][kid]
|
||||||
|
cached['key_id'] = kid
|
||||||
|
cached['label'] = acc_cfg.get('label', kid)
|
||||||
|
cached['workspace_id'] = acc_cfg.get('workspace_id') or cached.get('workspace_id')
|
||||||
|
accounts_list.append(cached)
|
||||||
|
else:
|
||||||
|
accounts_list.append({
|
||||||
|
'key_id': kid,
|
||||||
|
'label': acc_cfg.get('label', kid),
|
||||||
|
'workspace_id': acc_cfg.get('workspace_id'),
|
||||||
|
'rolling': None,
|
||||||
|
'weekly': None,
|
||||||
|
'monthly': None,
|
||||||
|
'session_expired': False,
|
||||||
|
'last_update_iso': None,
|
||||||
|
'error': 'not yet collected',
|
||||||
|
})
|
||||||
|
|
||||||
|
output = {
|
||||||
|
'ok': collected_count > 0,
|
||||||
|
'last_refresh_iso': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||||
|
'account_count': len(accounts_list),
|
||||||
|
'collected_count': collected_count,
|
||||||
|
'cdp_override_key_id': cdp_key_id,
|
||||||
|
'accounts': accounts_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_result(output)
|
||||||
|
|
||||||
|
ok = sum(1 for a in accounts_list if a.get('rolling') and a['rolling'].get('usage_percent') is not None)
|
||||||
|
err = sum(1 for a in accounts_list if a.get('error'))
|
||||||
|
log.info(f'Done: {len(accounts_list)} accounts, {ok} with data, {err} errored.')
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def save_result(data):
|
||||||
|
"""Persist to gateway/temp/usage_stats.json."""
|
||||||
|
ensure_dirs()
|
||||||
|
try:
|
||||||
|
with open(str(OUTPUT_FILE), 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
log.info(f'Saved to {OUTPUT_FILE}')
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'Failed to save: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
def load_result():
|
||||||
|
"""Return cached result or empty dict."""
|
||||||
|
if not OUTPUT_FILE.exists():
|
||||||
|
return {
|
||||||
|
'ok': False,
|
||||||
|
'error': 'no usage_stats.json yet',
|
||||||
|
'last_refresh_iso': None,
|
||||||
|
'accounts': []
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(str(OUTPUT_FILE), 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': str(e), 'accounts': []}
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(cdp_only=False):
|
||||||
|
"""One-shot collection."""
|
||||||
|
return collect_all(cdp_only=cdp_only)
|
||||||
|
|
||||||
|
|
||||||
|
def run_daemon(interval=DEFAULT_POLL_INTERVAL_SEC):
|
||||||
|
"""Daemon mode."""
|
||||||
|
ensure_dirs()
|
||||||
|
log.info(f'Daemon started, interval={interval}s')
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
collect_all()
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f'Daemon error: {e}')
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='OpenCode Go usage collector (Hybrid v3.0)')
|
||||||
|
parser.add_argument('--daemon', action='store_true', help='Run as daemon')
|
||||||
|
parser.add_argument('--interval', type=int, default=DEFAULT_POLL_INTERVAL_SEC,
|
||||||
|
help=f'Polling interval in seconds (default: {DEFAULT_POLL_INTERVAL_SEC})')
|
||||||
|
parser.add_argument('--print', action='store_true', help='Print result JSON')
|
||||||
|
parser.add_argument('--cdp-only', action='store_true', help='Only collect via CDP (skip HTTP)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.daemon:
|
||||||
|
run_daemon(interval=args.interval)
|
||||||
|
else:
|
||||||
|
result = run_once(cdp_only=args.cdp_only)
|
||||||
|
if args.print:
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
extract_cookies.py — 提取并保存 opencode.ai cookies (含 httpOnly) 到 per-account JSON
|
||||||
|
===============================================================================
|
||||||
|
通过 CDP proxy /cookies 端点提取 Chrome 当前登录账号的全部 cookies,
|
||||||
|
过滤 opencode.ai domain (含 auth.opencode.ai),保存到 cookies/{key_id}.json。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python extract_cookies.py <key_id> [--workspace-id <wrk_XXX>]
|
||||||
|
python extract_cookies.py key1 --workspace-id wrk_01KQT521KYE2P2QRZ10N8MVPXA
|
||||||
|
|
||||||
|
依赖: 仅 Python stdlib (urllib, json) + CDP proxy localhost:3456
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
CDP_PROXY_URL = 'http://localhost:3456'
|
||||||
|
COOKIES_DIR = Path(__file__).resolve().parent / 'cookies'
|
||||||
|
ACCOUNTS_FILE = Path(__file__).resolve().parent / 'accounts.json'
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_cookies_from_cdp():
|
||||||
|
"""Call CDP proxy /cookies endpoint, return list of cookie dicts."""
|
||||||
|
url = f'{CDP_PROXY_URL}/cookies'
|
||||||
|
req = urllib.request.Request(url, method='GET')
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
data = json.loads(resp.read().decode('utf-8'))
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
return data.get('cookies', data.get('result', {}).get('cookies', []))
|
||||||
|
|
||||||
|
|
||||||
|
def filter_opencode_cookies(cookies):
|
||||||
|
"""Filter to opencode.ai + auth.opencode.ai domain cookies."""
|
||||||
|
return [c for c in cookies if c.get('domain', '').endswith('opencode.ai')]
|
||||||
|
|
||||||
|
|
||||||
|
def save_cookies(key_id, cookies, workspace_id=None):
|
||||||
|
"""Save cookies to cookies/{key_id}.json along with metadata."""
|
||||||
|
COOKIES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_file = COOKIES_DIR / f'{key_id}.json'
|
||||||
|
data = {
|
||||||
|
'key_id': key_id,
|
||||||
|
'workspace_id': workspace_id,
|
||||||
|
'extracted_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||||
|
'cookie_count': len(cookies),
|
||||||
|
'cookies': cookies,
|
||||||
|
}
|
||||||
|
with open(str(out_file), 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
return out_file
|
||||||
|
|
||||||
|
|
||||||
|
def update_accounts_json(key_id, workspace_id):
|
||||||
|
"""Update the workspace_id for the given key_id in accounts.json."""
|
||||||
|
if not ACCOUNTS_FILE.exists() or not workspace_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(str(ACCOUNTS_FILE), 'r', encoding='utf-8-sig') as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
updated = False
|
||||||
|
for acc in cfg.get('accounts', []):
|
||||||
|
if acc.get('key_id') == key_id:
|
||||||
|
if not acc.get('workspace_id') and workspace_id:
|
||||||
|
acc['workspace_id'] = workspace_id
|
||||||
|
updated = True
|
||||||
|
break
|
||||||
|
if updated:
|
||||||
|
with open(str(ACCOUNTS_FILE), 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f'Updated accounts.json: {key_id} → workspace_id={workspace_id}')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print('Usage: python extract_cookies.py <key_id> [--workspace-id <wrk_XXX>]')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
key_id = sys.argv[1]
|
||||||
|
workspace_id = None
|
||||||
|
if '--workspace-id' in sys.argv:
|
||||||
|
idx = sys.argv.index('--workspace-id')
|
||||||
|
workspace_id = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||||
|
|
||||||
|
print(f'Extracting cookies for {key_id}...')
|
||||||
|
try:
|
||||||
|
all_cookies = fetch_cookies_from_cdp()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: CDP /cookies failed: {e}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'Total cookies from Chrome: {len(all_cookies)}')
|
||||||
|
oc_cookies = filter_opencode_cookies(all_cookies)
|
||||||
|
print(f'opencode.ai cookies: {len(oc_cookies)}')
|
||||||
|
for c in oc_cookies:
|
||||||
|
print(f' {c["name"]}: domain={c["domain"]} httpOnly={c.get("httpOnly", False)} len={len(c.get("value", ""))}')
|
||||||
|
|
||||||
|
if not oc_cookies:
|
||||||
|
print('ERROR: No opencode.ai cookies found — is Chrome logged in to an opencode account?')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
out_file = save_cookies(key_id, oc_cookies, workspace_id)
|
||||||
|
print(f'Saved {len(oc_cookies)} cookies to {out_file}')
|
||||||
|
|
||||||
|
if workspace_id:
|
||||||
|
update_accounts_json(key_id, workspace_id)
|
||||||
|
print(f'Workspace ID: {workspace_id}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -353,6 +353,86 @@ def _rdp_status():
|
|||||||
pass
|
pass
|
||||||
return {'ok': True, 'tunnel_running': tunnel_on, 'rdp_enabled': rdp_on, 'rdp_port': 3389, 'tunnel_host': 'root@47.115.32.206', 'tunnel_port': 8080}
|
return {'ok': True, 'tunnel_running': tunnel_on, 'rdp_enabled': rdp_on, 'rdp_port': 3389, 'tunnel_host': 'root@47.115.32.206', 'tunnel_port': 8080}
|
||||||
|
|
||||||
|
# ── OpenCode Go Usage Monitor helpers ──
|
||||||
|
# Reads cached aggregation from gateway/temp/usage_stats.json.
|
||||||
|
# Triggers asynchronous collection by spawning usage_collector.py in a daemon thread.
|
||||||
|
# Cache file path + collector script path:
|
||||||
|
_USAGE_CACHE_FILE = os.path.join(os.path.dirname(__file__), 'gateway', 'temp', 'usage_stats.json')
|
||||||
|
_USAGE_COLLECTOR_SCRIPT = os.path.join(_GATEWAY_SCRIPTS, 'usage_collector.py')
|
||||||
|
_usage_collector_running = False # module-level lock flag
|
||||||
|
|
||||||
|
def _now_iso():
|
||||||
|
"""UTC ISO timestamp (avoids importing datetime everywhere we need this)."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
||||||
|
|
||||||
|
def _usage_read_cache():
|
||||||
|
"""Return cached usage stats from temp/usage_stats.json, or empty payload if missing."""
|
||||||
|
if not os.path.isfile(_USAGE_CACHE_FILE):
|
||||||
|
return {'ok': False, 'error': 'no cached data yet (collect never ran)',
|
||||||
|
'last_refresh_iso': None, 'accounts': []}
|
||||||
|
try:
|
||||||
|
with open(_USAGE_CACHE_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
# Don't pollute with no-stdout fields; just return the JSON object
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': f'failed to read cache: {e}',
|
||||||
|
'last_refresh_iso': None, 'accounts': []}
|
||||||
|
|
||||||
|
def _usage_trigger_async():
|
||||||
|
"""
|
||||||
|
Spawn a daemon thread that runs usage_collector.py via subprocess.
|
||||||
|
Sets a module-level flag so concurrent calls don't overlap collections.
|
||||||
|
|
||||||
|
The collector runs in its own process so it cannot crash the bot.
|
||||||
|
Writes its result to gateway/temp/usage_stats.json (read by _usage_read_cache).
|
||||||
|
"""
|
||||||
|
global _usage_collector_running
|
||||||
|
if _usage_collector_running:
|
||||||
|
log('usage: collect_now already in-flight, skipping trigger')
|
||||||
|
return
|
||||||
|
_usage_collector_running = True
|
||||||
|
|
||||||
|
def _runner():
|
||||||
|
global _usage_collector_running
|
||||||
|
try:
|
||||||
|
import subprocess as _sp
|
||||||
|
log(f'usage: spawning usage_collector.py via {sys.executable}')
|
||||||
|
# Run collector as one-shot --print so its stdout is captured but not displayed
|
||||||
|
proc = _sp.run([sys.executable, _USAGE_COLLECTOR_SCRIPT],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
log(f'usage: collector finished (rc={proc.returncode}, '
|
||||||
|
f'stdout_len={len(proc.stdout)}, stderr_len={len(proc.stderr)})')
|
||||||
|
if proc.returncode != 0 and proc.stderr:
|
||||||
|
log(f'usage_collector.py stderr:\n{proc.stderr[-1500:]}')
|
||||||
|
except Exception as e:
|
||||||
|
log(f'usage: collector thread crashed: {e}')
|
||||||
|
finally:
|
||||||
|
_usage_collector_running = False
|
||||||
|
|
||||||
|
t = threading.Thread(target=_runner, name='usage_collector_runner', daemon=True)
|
||||||
|
t.start()
|
||||||
|
log('usage: collect_now background thread spawned')
|
||||||
|
|
||||||
|
# ── Auto-collect timer (every 5 min, keeps cookies alive) ──
|
||||||
|
_USAGE_AUTO_INTERVAL = 300 # 5 minutes
|
||||||
|
|
||||||
|
def _start_usage_auto_timer():
|
||||||
|
"""Start a daemon thread that auto-collects every 5 min (cookie keepalive + fresh data)."""
|
||||||
|
def _loop():
|
||||||
|
import time as _time
|
||||||
|
_time.sleep(10) # initial delay: let bot stabilize
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
_usage_trigger_async()
|
||||||
|
except Exception as e:
|
||||||
|
log(f'usage: auto-collect error: {e}')
|
||||||
|
_time.sleep(_USAGE_AUTO_INTERVAL)
|
||||||
|
t = threading.Thread(target=_loop, name='usage_auto_timer', daemon=True)
|
||||||
|
t.start()
|
||||||
|
log(f'usage: auto-timer started (interval={_USAGE_AUTO_INTERVAL}s)')
|
||||||
|
|
||||||
def _start_easytier():
|
def _start_easytier():
|
||||||
"""Start EasyTier on Windows."""
|
"""Start EasyTier on Windows."""
|
||||||
import subprocess as _sp
|
import subprocess as _sp
|
||||||
@@ -856,6 +936,23 @@ class _BridgeHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self._reply(400, {'ok': False, 'error': 'action must be start|stop|status'})
|
self._reply(400, {'ok': False, 'error': 'action must be start|stop|status'})
|
||||||
return
|
return
|
||||||
|
# /usage endpoint — OpenCode Go usage monitor (read cache / trigger collection)
|
||||||
|
# Pattern mirrors /rdp and /easytier. Two actions:
|
||||||
|
# - status: return cached gateway/temp/usage_stats.json (instant, ~1ms)
|
||||||
|
# - collect_now: spawn background thread running usage_collector.py (non-blocking,
|
||||||
|
# refresh takes ~10-15s); client should poll status a few seconds later
|
||||||
|
if path == "/usage":
|
||||||
|
action = body.get('action', 'status')
|
||||||
|
if action == 'status':
|
||||||
|
self._reply(200, _usage_read_cache())
|
||||||
|
elif action == 'collect_now':
|
||||||
|
_usage_trigger_async()
|
||||||
|
self._reply(200, {'ok': True,
|
||||||
|
'message': 'collection triggered (takes ~10-15s); poll GET /usage action=status shortly',
|
||||||
|
'triggered_at': _now_iso()})
|
||||||
|
else:
|
||||||
|
self._reply(400, {'ok': False, 'error': 'action must be status|collect_now'})
|
||||||
|
return
|
||||||
# /easytier endpoint — execute EasyTier action locally (no XMPP DM)
|
# /easytier endpoint — execute EasyTier action locally (no XMPP DM)
|
||||||
if path == "/easytier":
|
if path == "/easytier":
|
||||||
action = body.get("action", "")
|
action = body.get("action", "")
|
||||||
@@ -1198,6 +1295,7 @@ def main():
|
|||||||
_xmpp_ref = bot
|
_xmpp_ref = bot
|
||||||
|
|
||||||
_start_http_bridge()
|
_start_http_bridge()
|
||||||
|
_start_usage_auto_timer()
|
||||||
|
|
||||||
bot.connect(host=cfg["server"], port=cfg["port"])
|
bot.connect(host=cfg["server"], port=cfg["port"])
|
||||||
log(f"Connecting {cfg['jid']}@{cfg['server']}:{cfg['port']}")
|
log(f"Connecting {cfg['jid']}@{cfg['server']}:{cfg['port']}")
|
||||||
|
|||||||
Reference in New Issue
Block a user