Skill: shawntribbiani/kimi-quota-monitor (version 1.0.0)

--- name: kimi-quota-monitor description: Kimi (Kimi Chat) membership quota monitoring and daily reporting. Use when the user needs to (1) check current Kimi usage percentage, (2) set up or configure automated Kimi quota daily push to WeChat via openclaw, (3) calculate quota period cycles and KimiClaw sandbox deduction logic, or (4) troubleshoot Kimi quota fetching failures. Triggers on phrases like "Kimi额度", "quota日报", "会员额度", "额度监控". --- # Kimi Quota Monitor 抓取 Kimi 会员额度百分比，计算周期抵扣逻辑，推送到微信。 ## ⚠️ 使用前配置（必须） 本 skill 需要你填入自己的认证信息才能运行。打开 `scripts/fetch_quota.py`，修改**用户配置区**（文件顶部）： ### 1. Cookies 文件 将 Kimi 登录 cookies 导出为 JSON 文件，放在脚本同级目录（或指定绝对路径）。 **格式**（Playwright cookies 数组）： ```json [ {"name": "a1", "value": "xxx", "domain": ".kimi.com", "path": "/"}, {"name": "web_session", "value": "xxx", "domain": ".kimi.com", "path": "/"} ] ``` **导出方式**：在已登录 Kimi 的浏览器中，打开开发者工具 → Application → Cookies → 复制 `.kimi.com` 域下的所有 cookies，粘贴为 JSON 数组保存到 `kimi_cookies.json`。 ### 2. 微信推送目标 ID 获取你的 openclaw-weixin 用户 ID： ```bash openclaw message list-accounts --channel openclaw-weixin ``` 将 `TARGET_ID` 替换为你的 ID，格式为 `[REDACTED_SECRET]`。 ### 3. localStorage 认证数据 在已登录 Kimi 的浏览器中，打开开发者工具 → Application → Local Storage → `https://www.kimi.com`，复制以下键值： - `access_token` - `refresh_token` - `msh_user_id` - `msh_user_subscription_data` 填入 `LS_DATA` 字典中。 > ⚠️ 这些是敏感认证信息，请勿泄露给他人。 ## Quick Start ### 手动抓取额度 配置完成后，在 skill 目录执行： ```bash python3 scripts/fetch_quota.py ``` 脚本会自动： 1. 启动 Playwright headless Chrome 2. 加载 cookies 和 localStorage 登录 3. 访问 `kimi.com/membership/subscription` 4. 提取额度百分比 5. 计算周期和抵扣 6. 推送到微信 ### 配置定时任务 添加到 crontab： ``` 8 7 * * * /usr/bin/python3 /path/to/kimi-quota-monitor/scripts/fetch_quota.py >> /path/to/logs/kimi_quota.log 2>&1 ``` ## Dependencies - `playwright` Python package (`pip install playwright`) - System Chrome browser (`google-chrome` or `chromium`) - `openclaw` CLI with `openclaw-weixin` channel configured - `kimi_cookies.json` — Kimi login cookies (用户自行配置) ## How Quota Calculation Works - **Reset date**: 22nd of each month - **Cycle length**: total days of current month (28/29/30/31) - **Daily baseline**: `100% / month_days` - **KimiClaw sandbox est.**: 0.6% per day - **Deduction logic**: KimiClaw estimate subtracts from diff until diff reaches 0 See [references/quota_rules.md](references/quota_rules.md) for full calculation details, CSS selectors, and message template. ## Resources ### scripts/ - `fetch_quota.py` — Main script: fetch quota, calculate, push to WeChat - **Top section marked "用户配置区" must be filled before use** ### references/ - `quota_rules.md` — Calculation rules, page URLs, selectors, message format ## Files | File | Purpose | |------|---------| | `scripts/fetch_quota.py` | Core automation script (requires user config) | | `references/quota_rules.md` | Calculation rules and reference | | `kimi_cookies.json` | Kimi login cookies (user-provided, not included) |

path: fetch_quota.py
content: #!/usr/bin/env python3
"""
Kimi 额度自动抓取 - 带 cookie + localStorage 持久化
套路：加载 cookies + 设置 localStorage access_token → 访问 subscription 页 → 抓额度

使用前请按 SKILL.md 配置说明填入你自己的认证信息。
"""

import asyncio
import json
import os
import sys
from datetime import datetime
from pathlib import Path

from playwright.async_api import async_playwright

# ==========================
# ⚠️ 用户配置区 — 使用前必须修改
# ==========================

# 1. Cookies 文件路径：将 Kimi 登录 cookies 导出为 JSON 放在此路径
#    格式：Playwright cookies 数组 [{name, value, domain, path}, ...]
COOKIES_FILE = Path("kimi_cookies.json")

# 2. 微信推送目标 ID（openclaw-weixin 通道）
#    获取方式：openclaw message list-accounts --channel openclaw-weixin
TARGET_ID = "[REDACTED_SECRET]"

# 3. Kimi localStorage 认证数据
#    从浏览器开发者工具 → Application → Local Storage 复制
LS_DATA = {
    "access_token": "YOUR_ACCESS_TOKEN",
    "refresh_token": "YOUR_REFRESH_TOKEN",
    "msh_user_id": "YOUR_MSH_USER_ID",
    "msh_user_subscription_data": '{"currentMembershipLevel":25}',
}

# ==========================
# 以下为通用逻辑，通常无需修改
# ==========================

MONITOR_URL = "https://www.kimi.com/membership/subscription"


def wechat_push(message: str):
    """推送消息到微信（通过 openclaw CLI）"""
    import subprocess
    cmd = [
        "openclaw", "message", "send",
        "--channel", "openclaw-weixin",
        "--target", TARGET_ID,
        "--message", message,
    ]
    try:
        subprocess.run(cmd, capture_output=True, timeout=30)
    except Exception as e:
        print(f"[ERROR] Push failed: {e}")


def get_cycle_info(today: datetime):
    """获取当前额度周期信息（默认每月22日重置）"""
    year, month, day = today.year, today.month, today.day

    # 确定周期起止
    if day >= 22:
        cycle_start = datetime(year, month, 22)
        if month == 12:
            cycle_end = datetime(year + 1, 1, 21)
        else:
            cycle_end = datetime(year, month + 1, 21)
    else:
        if month == 1:
            cycle_start = datetime(year - 1, 12, 22)
        else:
            cycle_start = datetime(year, month - 1, 22)
        cycle_end = datetime(year, month, 21)

    cycle_days = (cycle_end - cycle_start).days + 1
    days_passed = (today - cycle_start).days + 1

    return cycle_start, cycle_end, cycle_days, days_passed


def save_cookies(cookies: list):
    with open(COOKIES_FILE, "w") as f:
        json.dump(cookies, f, indent=2)
    print(f"[INFO] Cookies saved to {COOKIES_FILE}")


def load_cookies() -> list:
    if not COOKIES_FILE.exists():
        return []
    try:
        with open(COOKIES_FILE) as f:
            cookies = json.load(f)
        print(f"[INFO] Loaded {len(cookies)} cookies")
        return cookies
    except Exception as e:
        print(f"[ERROR] Failed to load cookies: {e}")
        return []


async def fetch_quota(playwright) -> dict:
    browser = await playwright.chromium.launch(headless=True, channel="chrome")
    context = await browser.new_context(
        viewport={"width": 1280, "height": 800},
        user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    )

    # 加载 cookies
    cookies = load_cookies()
    if cookies:
        await context.add_cookies(cookies)
        print("[INFO] Cookies injected into browser context")

    page = await context.new_page()

    try:
        # Step 1: 访问主站，让 sameSite=Strict cookie 生效
        print("[INFO] Visiting www.kimi.com to warm up cookies...")
        await page.goto("https://www.kimi.com/", wait_until="domcontentloaded", timeout=30000)
        await asyncio.sleep(2)

        # Step 2: 注入 localStorage 认证数据
        print("[INFO] Injecting localStorage auth data...")
        for key, value in LS_DATA.items():
            await page.evaluate(f"localStorage.setItem('{key}', '{value}')")
        print("[INFO] localStorage injected")

        # Step 3: 刷新页面，让前端读取 localStorage 识别登录态
        print("[INFO] Refreshing page to activate auth state...")
        await page.reload(wait_until="domcontentloaded")
        await asyncio.sleep(3)

        # Step 4: 访问 subscription 页面
        print(f"[INFO] Navigating to {MONITOR_URL}")
        await page.goto(MONITOR_URL, wait_until="domcontentloaded", timeout=30000)
        await asyncio.sleep(2)

        body_text = await page.evaluate("() => document.body.innerText")
        print(f"[DEBUG] Page body preview: {body_text[:300]}")

        # 检查是否已登录（页面显示套餐名或订阅相关字样）
        if "订阅" not in body_text and "subscription" not in body_text.lower():
            print("[WARN] Not logged in or cookies expired")
            await browser.close()
            return {"need_login": True}
        print("[INFO] Auth state confirmed")

        # 提取额度 - 用 DOM selector 精确抓取
        usage_pct = await page.evaluate(r"""() => {
            const el = document.querySelector('.usage-title');
            if (el) {
                const m = el.textContent.match(/(\d+\.?\d*)%/);
                return m ? parseFloat(m[1]) : null;
            }
            // 备选：搜索包含 % 的元素
            const els = [...document.querySelectorAll('*')];
            const usageEl = els.find(e => {
                const t = e.textContent;
                return t && /\d+\.\d+%/.test(t) && e.className && e.className.includes('usage');
            });
            if (usageEl) {
                const m = usageEl.textContent.match(/(\d+\.?\d*)%/);
                return m ? parseFloat(m[1]) : null;
            }
            return null;
        }""")

        # 提取重置日期
        reset_date = await page.evaluate(r"""() => {
            const els = [...document.querySelectorAll('*')];
            const el = els.find(e => {
                const t = e.textContent;
                return t && t.includes('Reset time') && /\d{4}-\d{2}-\d{2}/.test(t);
            });
            if (el) {
                const m = el.textContent.match(/(\d{4}-\d{2}-\d{2})/);
                return m ? m[1] : null;
            }
            return null;
        }""")

        # 保存刷新后的 cookie（延长有效期）
        new_cookies = await context.cookies()
        save_cookies(new_cookies)

        await browser.close()
        print("[INFO] Browser closed")

        if usage_pct is not None:
            return {
                "usage_pct": usage_pct,
                "remaining_pct": round(100 - usage_pct, 2),
                "reset_date": reset_date or "",
            }
        else:
            return {"error": "Could not parse quota from page"}

    except Exception as e:
        print(f"[ERROR] During fetch: {e}")
        await browser.close()
        return {"error": str(e)}


async def main():
    print(f"\n{'='*50}")
    print(f"Kimi Quota Fetcher - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"{'='*50}")

    async with async_playwright() as playwright:
        result = await fetch_quota(playwright)

    if result is None:
        print("[ERROR] No result returned")
        return 1

    if result.get("need_login"):
        msg = (
            "⚠️ Kimi 额度监控\n"
            "登录态已过期，需要重新配置认证信息。\n"
            "请检查 cookies 和 localStorage token 是否有效。"
        )
        wechat_push(msg)
        print("[INFO] Login needed notification pushed to WeChat")
        return 2

    if "error" in result:
        print(f"[ERROR] {result['error']}")
        wechat_push(f"❌ Kimi 额度抓取失败：{result['error']}")
        return 1

    # 获取额度数据
    usage_pct = result["usage_pct"]
    remaining_pct = result["remaining_pct"]
    reset_date_str = result.get("reset_date", "")

    # 周期计算
    now = datetime.now()
    today = datetime(now.year, now.month, now.day)
    cycle_start, cycle_end, cycle_days, days_passed = get_cycle_info(today)
    days_remaining = cycle_days - days_passed

    start_str = cycle_start.strftime("%m.%d")
    end_str = cycle_end.strftime("%m.%d")

    # 计划用量 = 按周期均摊
    daily_plan = 100.0 / cycle_days
    planned_used = daily_plan * days_passed
    raw_balance = planned_used - usage_pct  # 原始差额（计划 - 实际）

    # KimiClaw预估 = 剩余天数 × 0.6%/天
    projected_extra = days_remaining * 0.6

    # 抵扣逻辑：KimiClaw预估先抵扣差额
    display_balance = max(raw_balance - projected_extra, 0)
    net_kimiclaw_extra = max(projected_extra - raw_balance, 0)
    if net_kimiclaw_extra <= 0:
        net_kimiclaw_extra = None

    # 进度条
    projected_total = usage_pct + (net_kimiclaw_extra or 0)
    projected_total = min(projected_total, 100.0)

    bar_filled = int((projected_total / 100.0) * 10)
    bar = "█" * bar_filled + "░" * (10 - bar_filled)

    today_str = today.strftime("%m.%d")

    # 组装消息
    msg_lines = [
        f"📊 Kimi 额度日报 · {today_str}",
        "",
        f"周期：{start_str} – {end_str}（共{cycle_days}天）",
        f"进度：{bar} {projected_total:.1f}%",
        "",
        "📌 截止今日：",
        f"   累计计划：{planned_used:.2f}%",
        f"   累计实际：{usage_pct:.2f}%",
        f"   差   额：+{display_balance:.2f}%",
    ]

    if net_kimiclaw_extra is not None:
        msg_lines.append(f"   KimiClaw预估：+{net_kimiclaw_extra:.1f}%")

    msg_lines.append("")
    msg_lines.append(f"💰 剩余总额度：{remaining_pct:.2f}%")

    msg = "\n".join(msg_lines)

    wechat_push(msg)
    print(f"[INFO] Quota pushed: used={usage_pct}%, remaining={remaining_pct}%")
    return 0


if __name__ == "__main__":
    exit_code = asyncio.run(main())
    sys.exit(exit_code)


path: quota_rules.md
content: # Kimi 额度监控规则参考

## 页面信息

- **额度页面**: `https://www.kimi.com/membership/subscription`
- **登录方式**: Playwright headless Chrome + cookies + localStorage (access_token/refresh_token)

## 抓取策略

额度百分比显示在会员页面，需要通过以下步骤获取：

1. 启动 Playwright `chromium.launch(channel='chrome', headless=True)`
2. 创建新 context，设置 `device_scale_factor=1`
3. 打开页面后注入 cookies（来自 `kimi_cookies.json`）
4. 注入 localStorage：`access_token`、`refresh_token`、`msh_user_id`
5. 刷新页面等待加载完成
6. 页面加载完成后提取额度信息
7. 关闭浏览器

## 周期计算规则

- **重置日期**: 每月 22 日（可修改脚本中的 `get_cycle_info` 逻辑调整）
- **每月总天数**: 当前月份的天数（28/29/30/31）
- **每日基准额度**: `100% / 月总天数`
- **KimiClaw 沙箱预估消耗**: 每天 0.6%

### 计算公式

```
已过天数 = 今天日期 - 22（如果今天 >= 22）
        或 今天日期 + (上月总天数 - 22)（如果今天 < 22，跨周期）

每日计划额度 = (已过天数) × (100 / 月总天数)

原始差额 = 每日计划额度 - 实际已用百分比

KimiClaw 预估 = 剩余天数 × 0.6%

显示差额 = max(原始差额 - KimiClaw预估, 0)
显示 KimiClaw = max(KimiClaw预估 - 原始差额, 0)  [为0时隐藏]
```

## 消息模板（实际推送格式）

```
📊 Kimi 额度日报 · 04.30

周期：04.22 – 05.21（共30天）
进度：[████████░░] 17.1%

📌 截止今日：
   累计计划：15.00%
   累计实际：17.06%
   差   额：+0.00%
   KimiClaw预估：+7.2%

💰 剩余总额度：82.94%
```

## 环境依赖

- `playwright` Python 包
- 系统 Chrome 浏览器（`google-chrome` 或 `chromium`）
- `openclaw` CLI（用于微信推送）

## 状态文件

- `kimi_cookies.json` — Kimi 登录 cookies（用户自行导出配置）
- `kimi_quota.log` — 运行日志


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)