diff --git a/gateway/scripts/templates/dashboard.html b/gateway/scripts/templates/dashboard.html
index e33568f..a9b124c 100644
--- a/gateway/scripts/templates/dashboard.html
+++ b/gateway/scripts/templates/dashboard.html
@@ -440,7 +440,13 @@ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_ena
}else if(a.error){
html+='⚠';
}else{
- html+=''+maxPct+'% max';
+ var statusTags='';
+ if(a.subscribed===false){
+ statusTags+='UNSUBSCRIBED';
+ }else if(a.renewal==='cancelled'){
+ statusTags+='⏳ 续订取消';
+ }
+ html+=statusTags+''+maxPct+'% max';
}
html+='';
if(a.session_expired||a.error){
diff --git a/gateway/scripts/usage_collector.py b/gateway/scripts/usage_collector.py
index 635141e..e84aa3f 100644
--- a/gateway/scripts/usage_collector.py
+++ b/gateway/scripts/usage_collector.py
@@ -292,6 +292,10 @@ def collect_via_http(key_id, account_cfg):
'last_update_iso': datetime.now(timezone.utc).isoformat(timespec='seconds'),
}
+ # Check subscription status from page text
+ sub_active = check_subscription_status(html)
+ renewal_status = account_cfg.get('renewal', 'active')
+
result = {
'key_id': key_id, 'label': account_cfg.get('label', key_id),
'workspace_id': parsed['workspace_id'],
@@ -299,8 +303,15 @@ def collect_via_http(key_id, account_cfg):
'monthly': parsed['monthly'], 'session_expired': False,
'last_update_iso': datetime.now(timezone.utc).isoformat(timespec='seconds'),
'error': None,
+ 'subscribed': sub_active,
+ 'renewal': renewal_status,
}
+ if not sub_active:
+ log.warning(f'[HTTP] {key_id} subscription NO LONGER ACTIVE — needs cleanup')
+ elif renewal_status == 'cancelled':
+ log.info(f'[HTTP] {key_id} renewal cancelled, but subscription still active')
+
log.info(f'[HTTP] SUCCESS {key_id}: '
f'rolling={result["rolling"]["usage_percent"]}% '
f'weekly={result["weekly"]["usage_percent"]}% '
@@ -497,6 +508,15 @@ def collect_via_cdp(accounts_cfg):
key_id = acc.get('key_id', ws_id)
break
+ # Check subscription status (CDP fetches via fetch(), not document.body, so check html_text)
+ sub_active = check_subscription_status(html_text)
+ renewal_status = 'active'
+ if accounts_cfg:
+ for acc in accounts_cfg.get('accounts', []):
+ if acc.get('workspace_id') == ws_id:
+ renewal_status = acc.get('renewal', 'active')
+ break
+
result = {
'key_id': key_id,
'label': label,
@@ -507,8 +527,15 @@ def collect_via_cdp(accounts_cfg):
'session_expired': False,
'last_update_iso': datetime.now(timezone.utc).isoformat(timespec='seconds'),
'error': None,
+ 'subscribed': sub_active,
+ 'renewal': renewal_status,
}
+ if not sub_active:
+ log.warning(f'[CDP] {key_id} subscription NO LONGER ACTIVE — needs cleanup')
+ elif renewal_status == 'cancelled':
+ log.info(f'[CDP] {key_id} renewal cancelled, but subscription still active')
+
log.info(f'[CDP] SUCCESS {key_id}: '
f'rolling={result["rolling"]["usage_percent"]}% '
f'weekly={result["weekly"]["usage_percent"]}% '
@@ -560,6 +587,22 @@ def parse_usage_from_html(html_text, workspace_id_hint):
}
+def check_subscription_status(html_text):
+ """
+ Check whether the SSR HTML page indicates an active subscription.
+
+ Looks for 'subscribed' text (case-insensitive) in the HTML.
+ Returns True if the user is still subscribed, False otherwise.
+ """
+ # Heuristic: page is loaded (no auth redirect) + contains "subscribed" keyword
+ if not html_text:
+ return False
+ lower = html_text.lower()
+ # Check for positive signal
+ subscribed = 'subscribed' in lower
+ return subscribed
+
+
# ═══════════════════════════════════════════════════════════════════════
# Cache merge logic
# ═══════════════════════════════════════════════════════════════════════
@@ -617,7 +660,6 @@ def collect_all(cdp_only=False):
# Skip non-OCG providers (Kimi etc.)
if provider != 'opencode_go':
continue
-
result = collect_via_http(key_id, acc_cfg)
merge_result_into_cache(cache, key_id, result)
diff --git a/gateway/scripts/usage_collector_kimi.py b/gateway/scripts/usage_collector_kimi.py
index e8ce46b..45c53b9 100644
--- a/gateway/scripts/usage_collector_kimi.py
+++ b/gateway/scripts/usage_collector_kimi.py
@@ -49,11 +49,218 @@ def cdp_eval(target_id, js, timeout=10):
return cdp_post(f"/eval?target={target_id}", js, timeout)
+def get_page_text(target_id):
+ """Get document.body.innerText via CDP eval, unwrap JSON envelope."""
+ js = "document.body ? document.body.innerText : ''"
+ raw = cdp_eval(target_id, js)
+ 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, min_pct_count=2, timeout=20, interval=1):
+ """Poll page text until keywords found or enough non-zero %s, or timeout.
+
+ Returns (text, attempts)."""
+ import re as _re
+ for attempt in range(int(timeout / interval)):
+ text = get_page_text(target_id)
+ # Check 1: keywords present
+ if keywords and all(k in text for k in keywords):
+ print(f"[kimi] Page ready — keywords found (attempt {attempt + 1})")
+ return text, attempt + 1
+ # Check 2: at least min_pct_count non-zero percentages
+ pcts = _re.findall(r'(\d+(?:\.\d+)?)%', text)
+ non_zero = sum(1 for p in pcts if float(p) > 0)
+ if non_zero >= min_pct_count:
+ print(f"[kimi] Page ready — {non_zero} non-zero %s (attempt {attempt + 1})")
+ return text, attempt + 1
+ time.sleep(interval)
+ text = get_page_text(target_id)
+ print(f"[kimi] Poll timeout ({timeout}s), using current state")
+ return text, int(timeout / interval)
+
+
+def extract_metric(text, keyword, after_window=200):
+ """Find percentage and date reset after a keyword in page text.
+
+ Returns (usage_percent, date_str, status, error_hint).
+ """
+ import re as _re
+ idx = text.find(keyword)
+ if idx < 0:
+ return None, None, "unknown", f"keyword '{keyword}' not found"
+ after = text[idx:idx + after_window]
+ m_pct = _re.search(r'(\d+(?:\.\d+)?)%', after)
+ m_date = _re.search(r'(\d{4}-\d{2}-\d{2})|(\d{2}-\d{2}\s+\d{2}:\d{2})', after)
+ pct = float(m_pct.group(1)) if m_pct else None
+ date_str = (m_date.group(1) or m_date.group(2)) if m_date else None
+ if pct is None:
+ return None, None, "parse_error", f"no % found near '{keyword}'"
+ status = "ok"
+ if pct >= 95:
+ status = "rate-limited"
+ elif pct >= 80:
+ status = "warn"
+ return pct, date_str, status, None
+
+
+def parse_date(dstr):
+ """Parse date string to seconds remaining from now."""
+ if not dstr:
+ return 0
+ now = datetime.now(timezone.utc)
+ try:
+ if '-' in dstr and ':' not in dstr:
+ # YYYY-MM-DD
+ d = datetime.strptime(dstr, "%Y-%m-%d")
+ return max(0, int((d.replace(tzinfo=timezone.utc) - now).total_seconds()))
+ elif '-' in dstr and ':' in dstr:
+ # MM-DD HH:MM — assume current year
+ year = now.year
+ d = datetime.strptime(dstr, "%m-%d %H:%M").replace(year=year, tzinfo=timezone.utc)
+ if d < now:
+ d = d.replace(year=year + 1)
+ return max(0, int((d - now).total_seconds()))
+ except Exception:
+ pass
+ return 0
+
+
+def try_collect(target_id):
+ """One round of collection: poll → parse → return metrics dict.
+
+ Returns {monthly_pct, monthly_reset, rolling_pct, rolling_reset,
+ weekly_pct, weekly_reset, page_text} or raises.
+ """
+ import re as _re
+
+ # Poll until page is ready
+ text, _attempts = poll_page_ready(target_id, keywords=["%"])
+
+ # Keyword-based extraction (order-independent, survives layout changes)
+ # Keywords extracted from actual Kimi page text (2026-07-19)
+ monthly_keywords = ["总使用量", "总用量", "月额度", "月"]
+ rolling_keywords = ["5 小时用量", "5小时用量", "5小时", "5h", "5 Hour"]
+ weekly_keywords = ["7 天用量", "7天用量", "7天", "7d", "7 Day"]
+
+ monthly_pct = monthly_date = monthly_status = monthly_err = None
+ rolling_pct = rolling_date = rolling_status = rolling_err = None
+ weekly_pct = weekly_date = weekly_status = weekly_err = None
+
+ for kw in monthly_keywords:
+ monthly_pct, monthly_date, monthly_status, monthly_err = extract_metric(text, kw)
+ if monthly_pct is not None:
+ break
+ for kw in rolling_keywords:
+ rolling_pct, rolling_date, rolling_status, rolling_err = extract_metric(text, kw)
+ if rolling_pct is not None:
+ break
+ for kw in weekly_keywords:
+ weekly_pct, weekly_date, weekly_status, weekly_err = extract_metric(text, kw)
+ if weekly_pct is not None:
+ break
+
+ # Fallback: if any metric still None, extract all percentages and assign positionally
+ none_count = sum(1 for x in [monthly_pct, rolling_pct, weekly_pct] if x is None)
+ if none_count > 0:
+ print(f"[kimi] Keyword matched {3 - none_count}/3 metrics, filling rest positionally")
+ pct_matches = list(_re.finditer(r'(\d+(?:\.\d+)?)%', text))
+ date_matches = list(_re.finditer(r'(\d{4}-\d{2}-\d{2})|(\d{2}-\d{2}\s+\d{2}:\d{2})', text))
+
+ entries = []
+ for m in pct_matches:
+ pct_val = float(m.group(1))
+ pct_pos = m.start()
+ best_date = None
+ for d in date_matches:
+ if d.start() > pct_pos:
+ best_date = d.group(1) or d.group(2)
+ break
+ entries.append((pct_val, best_date, pct_pos))
+
+ # Assign in order: monthly=first, rolling=second, weekly=third
+ metric_keys = ["monthly", "rolling", "weekly"]
+ current = {"monthly": monthly_pct, "rolling": rolling_pct, "weekly": weekly_pct}
+ for i, key in enumerate(metric_keys):
+ if current[key] is None and i < len(entries):
+ if key == "monthly":
+ monthly_pct = entries[i][0]
+ monthly_date = entries[i][1]
+ monthly_status = "ok"
+ elif key == "rolling":
+ rolling_pct = entries[i][0]
+ rolling_date = entries[i][1]
+ rolling_status = "ok"
+ elif key == "weekly":
+ weekly_pct = entries[i][0]
+ weekly_date = entries[i][1]
+ weekly_status = "ok"
+
+ # Fill None → 0 for output
+ monthly_pct = monthly_pct if monthly_pct is not None else 0
+ rolling_pct = rolling_pct if rolling_pct is not None else 0
+ weekly_pct = weekly_pct if weekly_pct is not None else 0
+
+ return {
+ "monthly_pct": monthly_pct,
+ "monthly_reset": parse_date(monthly_date),
+ "monthly_status": monthly_status if monthly_status else "ok",
+ "rolling_pct": rolling_pct,
+ "rolling_reset": parse_date(rolling_date),
+ "rolling_status": rolling_status if rolling_status else "ok",
+ "weekly_pct": weekly_pct,
+ "weekly_reset": parse_date(weekly_date),
+ "weekly_status": weekly_status if weekly_status else "ok",
+ "page_text": text,
+ "errors": [e for e in [monthly_err, rolling_err, weekly_err] if e],
+ }
+
+
+def ensure_cdp_proxy():
+ """Try to start CDP proxy if it's not running."""
+ import subprocess
+ result = cdp_get("/targets", timeout=3)
+ if isinstance(result, dict) and "error" not in result:
+ return True
+ if isinstance(result, list):
+ return True
+ # CDP proxy not running, try to start it
+ print("[kimi] CDP proxy not running, attempting to start...")
+ 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
+ )
+ print(f"[kimi] CDP start: {proc.stdout.strip()}")
+ if proc.returncode == 0:
+ # Verify
+ result = cdp_get("/targets", timeout=5)
+ if isinstance(result, list):
+ print("[kimi] CDP proxy started OK")
+ return True
+ except Exception as e:
+ print(f"[kimi] CDP auto-start failed: {e}")
+ print("[kimi] ERROR: CDP proxy unavailable and auto-start failed")
+ return False
+
+
def main():
print("[kimi] Starting collection...")
+ if not ensure_cdp_proxy():
+ return
+
# 1. Find tab with the membership subscription page
targets = cdp_get("/targets") or []
+ # Guard: CDP proxy may return {"error": ...} dict instead of list
+ if isinstance(targets, dict):
+ print(f"[kimi] ERROR: CDP proxy returned error: {targets.get('error', 'unknown')}")
+ print("[kimi] Is CDP proxy running on localhost:3456? Is Chrome remote debugging enabled?")
+ return
kimi_tab = None
for t in targets:
url = t.get("url", "")
@@ -64,15 +271,8 @@ def main():
if kimi_tab:
target_id = kimi_tab.get("targetId") or kimi_tab.get("id")
print(f"[kimi] Found quota tab: {target_id}")
- # Always reload page for fresh data
- cur_url = kimi_tab.get("url", "")
- if "tab=quota" not in cur_url:
- print(f"[kimi] Navigating to quota tab...")
- cdp_get(f"/navigate?target={target_id}&url={KIMI_CONSOLE}")
- else:
- print(f"[kimi] Reloading page for fresh data...")
- cdp_get(f"/navigate?target={target_id}&url={KIMI_CONSOLE}")
- time.sleep(5)
+ # Always reload for fresh data
+ cdp_get(f"/navigate?target={target_id}&url={KIMI_CONSOLE}")
else:
print(f"[kimi] No quota tab found, opening new tab...")
result = cdp_get(f"/new?url={KIMI_CONSOLE}")
@@ -81,99 +281,38 @@ def main():
else:
target_id = str(result)
print(f"[kimi] New tab: {target_id}")
- time.sleep(5)
if not target_id:
print("[kimi] ERROR: no target ID")
return
- # 2. Wait for page load + extract data
- print("[kimi] Extracting usage data...")
+ # 2. Collect with retry
+ metrics = None
+ for retry in range(2):
+ print(f"[kimi] Collection round {retry + 1}...")
+ metrics = try_collect(target_id)
+ print(f"[kimi] Monthly: {metrics['monthly_pct']}% | "
+ f"5h: {metrics['rolling_pct']}% | 7d: {metrics['weekly_pct']}%")
- # Simple extraction: get all text, parse with Python
- js_get_text = "document.body ? document.body.innerText : ''"
- raw_text = cdp_eval(target_id, js_get_text)
+ # Success criteria: at least one metric has non-zero value
+ if metrics["monthly_pct"] > 0 or metrics["rolling_pct"] > 0 or metrics["weekly_pct"] > 0:
+ print("[kimi] Non-zero data collected")
+ break
+ if retry == 0:
+ print("[kimi] All zero, reloading and retrying...")
+ cdp_get(f"/navigate?target={target_id}&url={KIMI_CONSOLE}")
+ else:
+ if metrics["errors"]:
+ print(f"[kimi] Warnings: {'; '.join(metrics['errors'])}")
- # CDP eval returns JSON: {"value":"text content"} — unwrap it
- try:
- parsed = json.loads(raw_text) if isinstance(raw_text, str) else raw_text
- text = parsed.get("value", "") if isinstance(parsed, dict) else str(raw_text)
- except (json.JSONDecodeError, AttributeError):
- text = str(raw_text)
-
- # Parse the text in Python
- import re
-
- # Membership page has three metrics:
- # 总用量 (monthly): N%, reset YYYY-MM-DD
- # 5小时用量 (5h): Code N%, reset MM-DD HH:MM
- # 7天用量 (7d): Code N%, reset MM-DD HH:MM
- # The percentages appear in order: total, 5h, 7d
-
- # Extract all percentage values with context
- pct_matches = list(re.finditer(r'(\d+(?:\.\d+)?)%', text))
- # Extract dates in format MM-DD HH:MM or YYYY-MM-DD
- date_matches = list(re.finditer(r'(\d{4}-\d{2}-\d{2})|(\d{2}-\d{2}\s+\d{2}:\d{2})', text))
-
- # Build list of (pct, date) pairs based on position in text
- entries = []
- for m in pct_matches:
- pct_val = float(m.group(1))
- pct_pos = m.start()
- # Find nearest date after this percentage
- best_date = None
- for d in date_matches:
- if d.start() > pct_pos:
- best_date = d.group(1) or d.group(2)
- break
- entries.append((pct_val, best_date, pct_pos))
-
- # Assign by order: first = total (monthly), second = 5h, third = 7d
- def parse_date(dstr):
- """Parse date string to seconds remaining."""
- if not dstr:
- return 0
- now = datetime.now(timezone.utc)
- try:
- if '-' in dstr and ':' not in dstr:
- # YYYY-MM-DD
- d = datetime.strptime(dstr, "%Y-%m-%d")
- return max(0, int((d.replace(tzinfo=timezone.utc) - now).total_seconds()))
- elif '-' in dstr and ':' in dstr:
- # MM-DD HH:MM — assume current year
- year = now.year
- d = datetime.strptime(dstr, "%m-%d %H:%M").replace(year=year, tzinfo=timezone.utc)
- if d < now:
- d = d.replace(year=year + 1)
- return max(0, int((d - now).total_seconds()))
- except Exception:
- pass
- return 0
-
- def get_status(pct):
- if pct >= 95: return "rate-limited"
- if pct >= 80: return "warn"
- return "ok"
-
- # Build metrics
- total_pct = entries[0][0] if len(entries) > 0 else 0
- total_reset = parse_date(entries[0][1]) if len(entries) > 0 else 0
- five_hr_pct = entries[1][0] if len(entries) > 1 else 0
- five_hr_reset = parse_date(entries[1][1]) if len(entries) > 1 else 0
- seven_d_pct = entries[2][0] if len(entries) > 2 else 0
- seven_d_reset = parse_date(entries[2][1]) if len(entries) > 2 else 0
-
- print(f"[kimi] Monthly: {total_pct}% | 5h: {five_hr_pct}% | 7d: {seven_d_pct}%")
-
- # Also take a screenshot for debugging
+ # 3. Take a screenshot for debugging
try:
cdp_get(f"/screenshot?target={target_id}&file={TEMP_DIR}/kimi_console.png")
print("[kimi] Screenshot saved")
except:
pass
- # Build output with correctly mapped metrics
- # rolling → 5小时用量, weekly → 7天用量, monthly → 总用量
+ # 4. Build output
output = {
"ok": True,
"last_refresh_iso": datetime.now(timezone.utc).isoformat(timespec="seconds"),
@@ -184,19 +323,19 @@ def main():
"provider": PROVIDER,
"workspace_id": "kimi",
"rolling": {
- "usage_percent": five_hr_pct,
- "reset_in_sec": five_hr_reset,
- "status": get_status(five_hr_pct),
+ "usage_percent": metrics["rolling_pct"],
+ "reset_in_sec": metrics["rolling_reset"],
+ "status": metrics["rolling_status"],
},
"weekly": {
- "usage_percent": seven_d_pct,
- "reset_in_sec": seven_d_reset,
- "status": get_status(seven_d_pct),
+ "usage_percent": metrics["weekly_pct"],
+ "reset_in_sec": metrics["weekly_reset"],
+ "status": metrics["weekly_status"],
},
"monthly": {
- "usage_percent": total_pct,
- "reset_in_sec": total_reset,
- "status": get_status(total_pct),
+ "usage_percent": metrics["monthly_pct"],
+ "reset_in_sec": metrics["monthly_reset"],
+ "status": metrics["monthly_status"],
},
"session_expired": False,
"last_update_iso": datetime.now(timezone.utc).isoformat(timespec="seconds"),