From 2598b286eb7725590630db992c327d762c2a90b6 Mon Sep 17 00:00:00 2001 From: hmo Date: Mon, 20 Jul 2026 23:18:40 +0800 Subject: [PATCH] fix: kimi collector crash on CDP error + auto-start CDP proxy + dashboard renewal badge - usage_collector_kimi: guard /targets dict response, auto-start CDP proxy if down - usage_collector: add subscribed/renewal fields to HTTP+CDP collection - dashboard: show UNSUBSCRIBED red badge / renewal-cancelled orange badge --- gateway/scripts/templates/dashboard.html | 8 +- gateway/scripts/usage_collector.py | 44 ++- gateway/scripts/usage_collector_kimi.py | 335 ++++++++++++++++------- 3 files changed, 287 insertions(+), 100 deletions(-) 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"),