* LICENSE —— MIT
* THIRD-PARTY-NOTICES —— 依赖清单、再分发合规说明(含随仓库分发的
Apache ECharts 5.6.0 / Apache-2.0)与自查清单
* CONTRIBUTING.md —— 开发环境、五层验证、必须遵守的不变量、提交规范
* SECURITY.md —— 漏洞私有报告渠道、已有措施、已知非目标
* CODE_OF_CONDUCT.md —— 改编自 Contributor Covenant 2.1
* .github/ —— Bug 报告 / 功能建议表单 + PR 模板
* .editorconfig —— 与 .gitattributes 保持一致
* 全部 Python / Shell 源文件加 SPDX-License-Identifier: MIT 头
* README 增加「开源与许可」章节与许可标识
195 行
7.5 KiB
Python
195 行
7.5 KiB
Python
# -*- coding: utf-8 -*-
|
||
# SPDX-License-Identifier: MIT
|
||
# Copyright (c) 2026 Wang Chuanli
|
||
|
||
"""云端用量接口客户端(纯 urllib,不依赖 requests/浏览器)。
|
||
|
||
接口:POST {api_base}/billing/meter/get-user-request-usage
|
||
body {"startTime":"YYYY-MM-DD HH:MM:SS","endTime":"...","pageNum":1,"pageSize":200}
|
||
响应 data.total=区间调用总数;data.data[]: requestId/credit/model/client/requestTime/input/inputTrunc
|
||
|
||
鉴权只靠 Cookie + User-Agent,两者都从数据库 settings 读(后台页面维护)。
|
||
"""
|
||
import json
|
||
import ssl
|
||
import urllib.error
|
||
import urllib.request
|
||
from datetime import timedelta
|
||
|
||
|
||
class ApiError(Exception):
|
||
"""接口 / 凭证类异常。cookie_expired=True 时表示需要更新凭证。"""
|
||
|
||
def __init__(self, msg, cookie_expired=False):
|
||
super().__init__(msg)
|
||
self.cookie_expired = cookie_expired
|
||
|
||
|
||
def _ssl_context(verify=True):
|
||
ctx = ssl.create_default_context()
|
||
if not verify:
|
||
# 仅在用户显式设置 ssl_verify=0 时走到这里:cookie 就是账号凭证,
|
||
# 关掉校验等于把凭证暴露给中间人,所以默认永远是校验的。
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = ssl.CERT_NONE
|
||
return ctx
|
||
|
||
|
||
def _call(api_base, path, body, cookie, ua, timeout, ssl_verify=True):
|
||
req = urllib.request.Request(api_base + path, data=json.dumps(body).encode(), method="POST")
|
||
for k, v in {
|
||
"accept": "application/json, text/plain, */*",
|
||
"content-type": "application/json",
|
||
"cookie": cookie,
|
||
"origin": api_base,
|
||
"referer": api_base + "/profile/plans-usage",
|
||
"x-client-platform": "web",
|
||
"user-agent": ua,
|
||
}.items():
|
||
req.add_header(k, v)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout,
|
||
context=_ssl_context(ssl_verify)) as r:
|
||
return json.loads(r.read().decode("utf-8"))
|
||
except ssl.SSLError as e:
|
||
raise ApiError("TLS_ERROR: 云端证书校验失败(%s)。若本机有自签/企业代理,"
|
||
"请在「配置管理」把 ssl_verify 设为 0 并自行承担风险。" % e)
|
||
except urllib.error.HTTPError as e:
|
||
if e.code in (401, 403):
|
||
raise ApiError("COOKIE_EXPIRED: Cookie 已失效或被拒绝(HTTP %d)" % e.code,
|
||
cookie_expired=True)
|
||
raise ApiError("HTTP_%d" % e.code)
|
||
except urllib.error.URLError as e:
|
||
raise ApiError("网络请求失败:%s" % e)
|
||
except (ValueError, OSError) as e:
|
||
raise ApiError("响应解析失败:%s" % e)
|
||
|
||
|
||
def fetch_range(start_dt, end_dt, cookie, ua, api_base, api_path,
|
||
page_size=200, timeout=30, max_pages=100, log=None,
|
||
ssl_verify=True):
|
||
"""按天切分拉取,返回 (明细列表, {日期: 云端 total})。
|
||
|
||
按天切分是为了拿到「整日 total」——增量窗口的 total 只是区间值,不能用于完整性比对。
|
||
"""
|
||
rows, totals = [], {}
|
||
day, last = start_dt.date(), end_dt.date()
|
||
while day <= last:
|
||
d = day.strftime("%Y-%m-%d")
|
||
s = start_dt.strftime("%Y-%m-%d %H:%M:%S") if day == start_dt.date() else d + " 00:00:00"
|
||
e = end_dt.strftime("%Y-%m-%d %H:%M:%S") if day == last else d + " 23:59:59"
|
||
body = {"startTime": s, "endTime": e, "pageNum": 1, "pageSize": page_size}
|
||
total, got = None, 0
|
||
while True:
|
||
js = _call(api_base, api_path, body, cookie, ua, timeout, ssl_verify=ssl_verify)
|
||
if js.get("code") != 0:
|
||
raise ApiError("API_ERROR: %s" % js.get("msg"))
|
||
dta = js.get("data") or {}
|
||
if total is None:
|
||
total = dta.get("total", 0)
|
||
batch = dta.get("data") or []
|
||
rows.extend(batch)
|
||
got += len(batch)
|
||
if not batch or got >= (total or 0) or body["pageNum"] >= max_pages:
|
||
break
|
||
body["pageNum"] += 1
|
||
totals[d] = total or 0
|
||
if log:
|
||
log(" %s 云端 %s 条" % (d, total if total is not None else "-"))
|
||
day += timedelta(days=1)
|
||
return rows, totals
|
||
|
||
|
||
def strip_jsonc(text):
|
||
"""去掉 JSONC 里的 // 与 /* */ 注释(VSCode settings.json 常见)。
|
||
|
||
注意:`/* */` 的扫描必须按字符前移 1(不是 2),并且要连收尾的 `*/` 一起跳过,
|
||
否则会把 `*/` 残留进结果,或(当收尾的 `*` 落在奇数下标时)永远匹配不到终止符。
|
||
"""
|
||
out, i, n = [], 0, len(text)
|
||
in_str = esc = False
|
||
while i < n:
|
||
c = text[i]
|
||
if in_str:
|
||
out.append(c)
|
||
if esc:
|
||
esc = False
|
||
elif c == "\\":
|
||
esc = True
|
||
elif c == '"':
|
||
in_str = False
|
||
i += 1
|
||
continue
|
||
if c == '"':
|
||
in_str = True
|
||
out.append(c)
|
||
i += 1
|
||
continue
|
||
if c == "/" and i + 1 < n and text[i + 1] == "/":
|
||
while i < n and text[i] != "\n":
|
||
i += 1
|
||
continue # 保留换行,行号不漂移
|
||
if c == "/" and i + 1 < n and text[i + 1] == "*":
|
||
i += 2
|
||
while i < n and not (text[i] == "*" and i + 1 < n and text[i + 1] == "/"):
|
||
i += 1
|
||
i += 2 if i + 1 < n else 0 # 跳过收尾的 */
|
||
continue
|
||
out.append(c)
|
||
i += 1
|
||
return "".join(out)
|
||
|
||
|
||
def read_vscode_creds():
|
||
"""从 VSCode 设置里读 codebuddyUsage.cookie / userAgent(一次性接管用)。
|
||
|
||
原脚本靠这个读凭证;迁移到本项目的 settings 表后只作为「导入来源」。
|
||
"""
|
||
import glob
|
||
import json as _json
|
||
import os
|
||
cands = []
|
||
appdata = os.environ.get("APPDATA", "")
|
||
home = os.path.expanduser("~")
|
||
if appdata:
|
||
cands += [os.path.join(appdata, "Code", "User", "settings.json"),
|
||
os.path.join(appdata, "Code - Insiders", "User", "settings.json"),
|
||
os.path.join(appdata, "Cursor", "User", "settings.json"),
|
||
os.path.join(appdata, "Trae", "User", "settings.json")]
|
||
cands += glob.glob(os.path.join(home, "AppData", "Roaming", "*", "User", "settings.json"))
|
||
seen, out = set(), []
|
||
for p in cands:
|
||
if p in seen or not os.path.exists(p):
|
||
continue
|
||
seen.add(p)
|
||
try:
|
||
cfg = _json.loads(strip_jsonc(open(p, encoding="utf-8").read()))
|
||
except Exception as e: # noqa: BLE001
|
||
out.append((p, None, None, "解析失败:%s" % e))
|
||
continue
|
||
cookie = (cfg.get("codebuddyUsage.cookie") or "").strip()
|
||
ua = (cfg.get("codebuddyUsage.userAgent") or "").strip()
|
||
out.append((p, cookie, ua, "ok" if cookie else "无 codebuddyUsage.cookie"))
|
||
return out
|
||
|
||
|
||
def normalize(r, max_prompt=2048):
|
||
"""云端明细 -> 入库字段。"""
|
||
ts = (r.get("requestTime") or "")[:19]
|
||
prompt = r.get("input") or r.get("inputTrunc") or ""
|
||
prompt = " ".join(str(prompt).split()) # 折叠换行
|
||
if max_prompt and len(prompt) > max_prompt:
|
||
prompt = prompt[:max_prompt]
|
||
try:
|
||
credit = float(r.get("credit") or 0)
|
||
except (TypeError, ValueError):
|
||
credit = 0.0
|
||
return {
|
||
"request_id": (r.get("requestId") or "").strip(),
|
||
"ts": ts,
|
||
"model": (str(r.get("model") or "-").strip() or "-"),
|
||
"client": (str(r.get("client") or "-").strip() or "-"),
|
||
"credits": round(credit, 2),
|
||
"prompt": prompt,
|
||
}
|