From c11222e55c23aaa512223fceedba56cde7c841ed Mon Sep 17 00:00:00 2001 From: hmo Date: Tue, 21 Jul 2026 00:56:00 +0800 Subject: [PATCH] feat: local Kimi collection via Playwright + headless Chrome on 246 - usage_collector_kimi_local.py: Playwright connect_over_cdp to local Chrome - kimi_login.py: interactive login helper for headless Chrome - Replaces SSH-to-Windows approach, removes single point of failure - Chrome CDP service: chrome-kimi-cdp.service (systemd) --- gateway/scripts/kimi_login.py | 89 ++++++ gateway/scripts/usage_collector_kimi_local.py | 253 ++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 gateway/scripts/kimi_login.py create mode 100644 gateway/scripts/usage_collector_kimi_local.py diff --git a/gateway/scripts/kimi_login.py b/gateway/scripts/kimi_login.py new file mode 100644 index 0000000..a584a21 --- /dev/null +++ b/gateway/scripts/kimi_login.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +kimi_login.py — 在 246 headless Chrome 上登录 Kimi +==================================================== +用法(需要 X11 forwarding 或手动操作): + ssh -X hmo@192.168.1.246 + python3 kimi_login.py + +或者无头模式下手动注入 cookies: + python3 kimi_login.py --inject-cookies '{"name":"...","value":"...","domain":".kimi.com"}' + +登录成功后,cookies 自动保存在 /home/hmo/chrome-kimi-profile/ 下, +后续 chrome-kimi-cdp.service 重启后仍然保持登录态。 +""" +import json +import sys +import time + +from playwright.sync_api import sync_playwright + +CDP_URL = "http://127.0.0.1:9222" +KIMI_URL = "https://www.kimi.com/" + + +def check_logged_in(page): + """Check if Kimi is logged in by looking for user avatar or quota page access.""" + try: + page.goto("https://www.kimi.com/membership/subscription?tab=quota", + wait_until="networkidle", timeout=15000) + text = page.inner_text("body") or "" + if "login" in page.url or "passport" in page.url: + return False + # If we can see usage data, we're logged in + if "%" in text: + return True + return False + except Exception: + return False + + +def main(): + print("[kimi-login] Connecting to CDP...") + + with sync_playwright() as p: + try: + browser = p.chromium.connect_over_cdp(CDP_URL) + except Exception as e: + print(f"[kimi-login] ERROR: Cannot connect to CDP: {e}") + print("[kimi-login] Is chrome-kimi-cdp.service running?") + sys.exit(1) + + context = browser.contexts[0] if browser.contexts else browser.new_context() + page = context.new_page() + + # Check if already logged in + print("[kimi-login] Checking current login status...") + if check_logged_in(page): + print("[kimi-login] ✅ Already logged in!") + print("[kimi-login] Cookies are saved in /home/hmo/chrome-kimi-profile/") + return + + print("[kimi-login] Not logged in. Opening Kimi login page...") + print("[kimi-login] " + "=" * 50) + print("[kimi-login] If running with X11 forwarding (ssh -X), a browser window should appear.") + print("[kimi-login] Please login manually in the browser window.") + print("[kimi-login] Waiting up to 120 seconds for login...") + print("[kimi-login] " + "=" * 50) + + page.goto(KIMI_URL, wait_until="networkidle", timeout=30000) + + # Wait for user to login (poll every 3 seconds, up to 120s) + for i in range(40): + time.sleep(3) + current_url = page.url + print(f"[kimi-login] Checking... ({(i + 1) * 3}s) URL: {current_url[:80]}") + + if "login" not in current_url and "passport" not in current_url: + # Might be logged in now + if check_logged_in(page): + print("[kimi-login] ✅ Login successful!") + print("[kimi-login] Cookies saved to /home/hmo/chrome-kimi-profile/") + return + + print("[kimi-login] ❌ Login timeout (120s). Please try again.") + print("[kimi-login] Make sure X11 forwarding is enabled: ssh -X hmo@192.168.1.246") + + +if __name__ == "__main__": + main() diff --git a/gateway/scripts/usage_collector_kimi_local.py b/gateway/scripts/usage_collector_kimi_local.py new file mode 100644 index 0000000..f58b310 --- /dev/null +++ b/gateway/scripts/usage_collector_kimi_local.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +usage_collector_kimi_local.py — Kimi 用量采集(Playwright + 本地 CDP) +===================================================================== +连接本地 Chrome CDP (127.0.0.1:9222, chrome-kimi-cdp.service), +打开 Kimi 控制台,提取用量数据,保存到 usage_stats_kimi.json。 + +替代旧的 SSH-to-Windows 方案,直接在 246 本地运行。 + +调用方式: python3 usage_collector_kimi_local.py +""" +import json +import os +import re +import sys +import time +from pathlib import Path +from datetime import datetime, timezone + +from playwright.sync_api import sync_playwright + +CDP_URL = "http://127.0.0.1:9222" +KIMI_CONSOLE = "https://www.kimi.com/membership/subscription?tab=quota" + +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_kimi.json" +KEY_ID = "key7" +PROVIDER = "kimi" + + +def extract_metric(text, keyword, after_window=200): + """Find percentage and date reset after a keyword in page text.""" + 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: + 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: + 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 collect_metrics(page_text): + """Parse usage metrics from page text.""" + 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(page_text, kw) + if monthly_pct is not None: + break + for kw in rolling_keywords: + rolling_pct, rolling_date, rolling_status, rolling_err = extract_metric(page_text, kw) + if rolling_pct is not None: + break + for kw in weekly_keywords: + weekly_pct, weekly_date, weekly_status, weekly_err = extract_metric(page_text, kw) + if weekly_pct is not None: + break + + # Fallback: positional assignment if keywords miss + 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+)?)%', page_text)) + date_matches = list(re.finditer(r'(\d{4}-\d{2}-\d{2})|(\d{2}-\d{2}\s+\d{2}:\d{2})', page_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)) + + 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" + + return { + "monthly_pct": monthly_pct if monthly_pct is not None else 0, + "monthly_reset": parse_date(monthly_date), + "monthly_status": monthly_status or "ok", + "rolling_pct": rolling_pct if rolling_pct is not None else 0, + "rolling_reset": parse_date(rolling_date), + "rolling_status": rolling_status or "ok", + "weekly_pct": weekly_pct if weekly_pct is not None else 0, + "weekly_reset": parse_date(weekly_date), + "weekly_status": weekly_status or "ok", + "errors": [e for e in [monthly_err, rolling_err, weekly_err] if e], + } + + +def main(): + print("[kimi] Starting local Playwright collection...") + + with sync_playwright() as p: + # Connect to the persistent Chrome CDP service + try: + browser = p.chromium.connect_over_cdp(CDP_URL) + except Exception as e: + print(f"[kimi] ERROR: Cannot connect to CDP at {CDP_URL}: {e}") + print("[kimi] Is chrome-kimi-cdp.service running? (systemctl status chrome-kimi-cdp)") + sys.exit(1) + + # Use the default browser context (has persistent profile with login session) + context = browser.contexts[0] if browser.contexts else browser.new_context() + + # Check if already logged in by looking for existing kimi pages + kimi_page = None + for page in context.pages: + if "kimi.com" in page.url: + kimi_page = page + print(f"[kimi] Reusing existing Kimi tab: {page.url}") + break + + if not kimi_page: + kimi_page = context.new_page() + + # Navigate to the quota page + print(f"[kimi] Navigating to {KIMI_CONSOLE}") + kimi_page.goto(KIMI_CONSOLE, wait_until="networkidle", timeout=30000) + + # Wait for usage data to render (poll for % signs) + page_text = "" + for attempt in range(20): + page_text = kimi_page.inner_text("body") or "" + pcts = re.findall(r'(\d+(?:\.\d+)?)%', page_text) + non_zero = sum(1 for pct in pcts if float(pct) > 0) + if non_zero >= 2: + print(f"[kimi] Page ready — {non_zero} non-zero %s (attempt {attempt + 1})") + break + time.sleep(1) + else: + print(f"[kimi] Warning: page may not have fully loaded") + + # Check for login redirect + current_url = kimi_page.url + if "login" in current_url or "passport" in current_url: + print(f"[kimi] ERROR: Redirected to login page: {current_url}") + print("[kimi] Need to login first — run: python3 kimi_login.py") + browser.close() + sys.exit(1) + + # Parse metrics + metrics = collect_metrics(page_text) + print(f"[kimi] Monthly: {metrics['monthly_pct']}% | " + f"5h: {metrics['rolling_pct']}% | 7d: {metrics['weekly_pct']}%") + + if metrics["errors"]: + print(f"[kimi] Warnings: {'; '.join(metrics['errors'])}") + + # Screenshot for debugging + try: + kimi_page.screenshot(path=str(TEMP_DIR / "kimi_console.png")) + print("[kimi] Screenshot saved") + except Exception: + pass + + # Don't close the page — leave it open for next collection + # browser.close() would kill the entire CDP service + + # Build output + output = { + "ok": True, + "last_refresh_iso": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "provider": PROVIDER, + "accounts": [{ + "key_id": KEY_ID, + "label": "key7 (Kimi)", + "provider": PROVIDER, + "workspace_id": "kimi", + "rolling": { + "usage_percent": metrics["rolling_pct"], + "reset_in_sec": metrics["rolling_reset"], + "status": metrics["rolling_status"], + }, + "weekly": { + "usage_percent": metrics["weekly_pct"], + "reset_in_sec": metrics["weekly_reset"], + "status": metrics["weekly_status"], + }, + "monthly": { + "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"), + "error": None, + }], + } + + with open(str(OUTPUT_FILE), "w", encoding="utf-8") as f: + json.dump(output, f, ensure_ascii=False, indent=2) + print(f"[kimi] Output saved to {OUTPUT_FILE}") + + +if __name__ == "__main__": + main()