feat: SenseNova Token Plan余量监控(参考OCG/Kimi CDP模式)
- usage_collector_sensenova.py: CDP刮platform.sensenova.cn/console三模型余量 (锚定'模型名+换行+数字%剩余'防图例区误匹配) - dashboard: /api/usage和/api/keys合并sensenova数据; refresh流程加SSH采集+scp回传 - accounts.json注册key8/9/10(同一SenseNova key三模型视角)
This commit is contained in:
@@ -1218,6 +1218,19 @@ def api_usage():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. SenseNova 数据(2026-07-23 新增:Token Plan 三模型余量)
|
||||
sn_file = TEMP_DIR / "usage_stats_sensenova.json"
|
||||
if sn_file.exists():
|
||||
try:
|
||||
with open(str(sn_file), "r", encoding="utf-8") as f:
|
||||
sn_data = json.load(f)
|
||||
for a in sn_data.get("accounts", []):
|
||||
a["provider"] = a.get("provider", "sensenova")
|
||||
accounts.append(a)
|
||||
providers["sensenova"] = providers.get("sensenova", 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not accounts:
|
||||
return jsonify({"ok": False, "error": "no data yet",
|
||||
"last_refresh_iso": None, "accounts": [], "providers": {}})
|
||||
@@ -1272,6 +1285,27 @@ def api_usage_refresh():
|
||||
]
|
||||
proc3 = subprocess.run(scp_cmd, capture_output=True, timeout=15)
|
||||
log.info(f"usage: Kimi scp done (rc={proc3.returncode})")
|
||||
|
||||
# 4. SenseNova 远程采集(SSH 到 Windows,CDP 刮 console 余量)
|
||||
try:
|
||||
log.info("usage: starting SenseNova collector via SSH")
|
||||
sn_cmd = [
|
||||
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
||||
"-o", "BatchMode=yes", "hmo@192.168.1.16",
|
||||
r'"C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\AgentsMeeting\gateway\scripts\usage_collector_sensenova.py"'
|
||||
]
|
||||
proc4 = subprocess.run(sn_cmd, capture_output=True, text=True, timeout=90)
|
||||
log.info(f"usage: SenseNova done (rc={proc4.returncode})")
|
||||
if proc4.returncode != 0 and proc4.stderr:
|
||||
log.warning(f"usage_sensenova stderr: {proc4.stderr[-300:]}")
|
||||
scp4 = subprocess.run([
|
||||
"scp", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
||||
"hmo@192.168.1.16:D:/F/NewI/opencode/daily-workspace/projects/AgentsMeeting/gateway/temp/usage_stats_sensenova.json",
|
||||
str(TEMP_DIR / "usage_stats_sensenova.json")
|
||||
], capture_output=True, timeout=15)
|
||||
log.info(f"usage: SenseNova scp done (rc={scp4.returncode})")
|
||||
except Exception as e:
|
||||
log.warning(f"usage: SenseNova collection failed: {e}")
|
||||
except Exception as e:
|
||||
log.warning(f"usage: Kimi collection failed: {e}")
|
||||
except Exception as e:
|
||||
@@ -1314,6 +1348,13 @@ def api_keys():
|
||||
kimi_data = json.load(f)
|
||||
for a in kimi_data.get("accounts", []):
|
||||
usage_by_kid[a.get("key_id", "")] = a
|
||||
# SenseNova 独立文件(2026-07-23 新增)
|
||||
sn_file = TEMP_DIR / "usage_stats_sensenova.json"
|
||||
if sn_file.exists():
|
||||
with open(str(sn_file), "r", encoding="utf-8") as f:
|
||||
sn_data = json.load(f)
|
||||
for a in sn_data.get("accounts", []):
|
||||
usage_by_kid[a.get("key_id", "")] = a
|
||||
|
||||
# 合并
|
||||
keys_out = []
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
usage_collector_sensenova.py — SenseNova Token Plan 用量采集(CDP 浏览器自动化)
|
||||
==========================================================
|
||||
连接 Chrome CDP proxy (localhost:3456),打开 SenseNova 控制台,
|
||||
提取 3 个模型的 5 小时窗口剩余量,保存到 usage_stats_sensenova.json。
|
||||
|
||||
控制台(2026-07-23 实测):
|
||||
sensenova-6.7-flash-lite 99.73%剩余
|
||||
sensenova-u1-fast 100%剩余
|
||||
deepseek-v4-flash 99.80%剩余
|
||||
公测期免费,各模型 5 小时窗口独立计数,账户下所有 Key 共享配额。
|
||||
|
||||
调用方式: python usage_collector_sensenova.py
|
||||
"""
|
||||
import json, os, sys, time, urllib.request, re
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
CDP = "http://localhost:3456"
|
||||
CONSOLE_URL = "https://platform.sensenova.cn/console"
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
GATEWAY_DIR = SCRIPT_DIR.parent
|
||||
TEMP_DIR = GATEWAY_DIR / "temp"
|
||||
os.makedirs(str(TEMP_DIR), exist_ok=True)
|
||||
|
||||
OUTPUT_FILE = TEMP_DIR / "usage_stats_sensenova.json"
|
||||
PROVIDER = "sensenova"
|
||||
|
||||
# 控制台三个模型 → pseudo-key 映射(/api/keys 展示用)
|
||||
MODEL_KEYS = [
|
||||
("sensenova-6.7-flash-lite", "key8", "SenseNova 6.7 Flash-Lite"),
|
||||
("sensenova-u1-fast", "key9", "SenseNova U1-Fast"),
|
||||
("deepseek-v4-flash", "key10", "SenseNova DeepSeek-V4-Flash"),
|
||||
]
|
||||
|
||||
|
||||
def cdp_get(path, timeout=10):
|
||||
try:
|
||||
with urllib.request.urlopen(f"{CDP}{path}", timeout=timeout) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def cdp_post(path, body="", timeout=15):
|
||||
try:
|
||||
data = body.encode("utf-8") if isinstance(body, str) else body
|
||||
req = urllib.request.Request(f"{CDP}{path}", data=data, method="POST")
|
||||
req.add_header("Content-Type", "text/plain")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.read().decode()
|
||||
except Exception as e:
|
||||
return f"ERROR: {e}"
|
||||
|
||||
|
||||
def cdp_eval(target_id, js, timeout=10):
|
||||
return cdp_post(f"/eval?target={target_id}", js, timeout)
|
||||
|
||||
|
||||
def get_page_text(target_id):
|
||||
raw = cdp_eval(target_id, "document.body ? document.body.innerText : ''")
|
||||
try:
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
||||
return parsed.get("value", "") if isinstance(parsed, dict) else str(raw)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
return str(raw)
|
||||
|
||||
|
||||
def poll_page_ready(target_id, keywords=None, timeout=25, interval=1):
|
||||
for attempt in range(int(timeout / interval)):
|
||||
text = get_page_text(target_id)
|
||||
if keywords and all(k in text for k in keywords):
|
||||
print(f"[sensenova] ready (attempt {attempt + 1})")
|
||||
return text
|
||||
time.sleep(interval)
|
||||
print(f"[sensenova] poll timeout, using current state")
|
||||
return get_page_text(target_id)
|
||||
|
||||
|
||||
def extract_model_quotas(text):
|
||||
"""从控制台文本提取各模型剩余百分比。
|
||||
实测格式(2026-07-23):'sensenova-6.7-flash-lite\\n\\n99.73%剩余\\n\\nsensenova-u1-fast\\n\\n100%剩余...'
|
||||
注意图表图例区有模型名连写(sensenova-6.7-flash-litesensenova-u1-fast...),
|
||||
必须锚定"模型名+换行+数字%剩余"结构,否则会误匹配图例位置抓到别家数值。
|
||||
返回 {model_name: remaining_pct}"""
|
||||
quotas = {}
|
||||
for model, _, _ in MODEL_KEYS:
|
||||
m = re.search(re.escape(model) + r'\s*\n+\s*(\d+(?:\.\d+)?)\s*%\s*剩余', text)
|
||||
if m:
|
||||
quotas[model] = float(m.group(1))
|
||||
return quotas
|
||||
|
||||
|
||||
def ensure_cdp_proxy():
|
||||
result = cdp_get("/targets", timeout=3)
|
||||
if isinstance(result, list):
|
||||
return True
|
||||
print("[sensenova] CDP proxy not running, attempting to start...")
|
||||
import subprocess
|
||||
ps_script = r"D:\F\NewI\opencode\daily-workspace\.opencode\scripts\start-cdp-proxy.ps1"
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["powershell", "-ExecutionPolicy", "Bypass", "-File", ps_script],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
if proc.returncode == 0 and isinstance(cdp_get("/targets", timeout=5), list):
|
||||
print("[sensenova] CDP proxy started OK")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[sensenova] CDP auto-start failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def find_or_open_console_tab():
|
||||
targets = cdp_get("/targets")
|
||||
if not isinstance(targets, list):
|
||||
return None
|
||||
for t in targets:
|
||||
if "platform.sensenova.cn" in (t.get("url") or ""):
|
||||
return t.get("targetId") or t.get("id")
|
||||
# open new background tab
|
||||
r = cdp_get(f"/new?url={CONSOLE_URL}", timeout=30)
|
||||
if isinstance(r, dict) and r.get("targetId"):
|
||||
return r["targetId"]
|
||||
print(f"[sensenova] open tab failed: {r}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("[sensenova] Starting collection...")
|
||||
if not ensure_cdp_proxy():
|
||||
print("[sensenova] ERROR: CDP proxy unavailable")
|
||||
sys.exit(1)
|
||||
|
||||
tid = find_or_open_console_tab()
|
||||
if not tid:
|
||||
print("[sensenova] ERROR: cannot get console tab")
|
||||
sys.exit(1)
|
||||
|
||||
text = poll_page_ready(tid, keywords=["剩余"])
|
||||
quotas = extract_model_quotas(text)
|
||||
print(f"[sensenova] quotas: {quotas}")
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
accounts = []
|
||||
for model, key_id, label in MODEL_KEYS:
|
||||
remaining = quotas.get(model)
|
||||
if remaining is None:
|
||||
accounts.append({
|
||||
"key_id": key_id, "label": label, "provider": PROVIDER,
|
||||
"last_update_iso": now_iso,
|
||||
"error": "quota not found on console page",
|
||||
"rolling": {"usage_percent": None, "reset_in_sec": None, "status": "unknown"},
|
||||
})
|
||||
continue
|
||||
usage = round(100.0 - remaining, 2)
|
||||
status = "ok" if usage < 80 else ("warn" if usage < 95 else "rate-limited")
|
||||
accounts.append({
|
||||
"key_id": key_id, "label": label, "provider": PROVIDER,
|
||||
"last_update_iso": now_iso,
|
||||
"error": None,
|
||||
"rolling": {"usage_percent": usage, "reset_in_sec": 5 * 3600, "status": status},
|
||||
"monthly": None, "weekly": None,
|
||||
"remaining_percent": remaining,
|
||||
"note": "公测免费·5小时窗口·账户所有Key共享",
|
||||
})
|
||||
|
||||
out = {
|
||||
"source": "usage_collector_sensenova.py (CDP scrape of platform.sensenova.cn/console)",
|
||||
"collected_at": now_iso,
|
||||
"accounts": accounts,
|
||||
}
|
||||
with open(str(OUTPUT_FILE), "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, ensure_ascii=False, indent=2)
|
||||
print(f"[sensenova] saved → {OUTPUT_FILE} ({sum(1 for a in accounts if not a.get('error'))}/3 models)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user