文件
workbuddy-portal/workbuddy_portal/client.py
T
wangchuanli 23799b4ea5 feat(oss): 补齐开源声明体系(MIT + 第三方声明 + 贡献/安全/行为准则)
* 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 增加「开源与许可」章节与许可标识
2026-09-14 16:15:26 +08:00

195 行
7.5 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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,
}