From 0a3986238e80f18cf425bca778e883a94f0afaaa Mon Sep 17 00:00:00 2001 From: hmo Date: Thu, 23 Jul 2026 18:23:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SenseNova=20Token=20Plan=E4=BD=99?= =?UTF-8?q?=E9=87=8F=E7=9B=91=E6=8E=A7=EF=BC=88=E5=8F=82=E8=80=83OCG/Kimi?= =?UTF-8?q?=20CDP=E6=A8=A1=E5=BC=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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三模型视角) --- gateway/scripts/dashboard.py | 41 +++++ gateway/scripts/usage_collector_sensenova.py | 180 +++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 gateway/scripts/usage_collector_sensenova.py diff --git a/gateway/scripts/dashboard.py b/gateway/scripts/dashboard.py index 0c29d78..a6c54f0 100644 --- a/gateway/scripts/dashboard.py +++ b/gateway/scripts/dashboard.py @@ -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 = [] diff --git a/gateway/scripts/usage_collector_sensenova.py b/gateway/scripts/usage_collector_sensenova.py new file mode 100644 index 0000000..d3b3738 --- /dev/null +++ b/gateway/scripts/usage_collector_sensenova.py @@ -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()