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:
hmo
2026-07-15 22:30:20 +08:00
parent ac34556588
commit fefdc29d06
7 changed files with 1259 additions and 2 deletions
+5
View File
@@ -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
+81 -1
View File
@@ -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)
+120
View File
@@ -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)"
]
}
}
+100 -1
View File
@@ -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>';
+739
View File
@@ -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()
+98
View File
@@ -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']}")