Files
token-list-share/deploy/bailian-probe.py
Charles7c 2419141284 接入百炼额度探针、重置卡管理与周期/自动刷新升级
- 百炼 Token Plan:探针(cookie 网关)每 5 分钟写快照,卡片展示 7 天窗口、订阅到期与重置卡
- 重置卡使用:reset-card-server 本机服务经 Admin Key+卡归属校验后代发 use,前端二次确认;入口默认 CSS 隐藏(localStorage bailian-reset-card=1 开启),探针本体只读不碰 use
- 时间周期:新增近 7 天/近 30 天预设,筛选行独立成一行,分组页统计随区间变化
- list/groups 每 5 分钟自动刷新,后台标签页暂停、回窗补拉
- deploy.sh 一键 web/cookie/probe/nginx/status/all;脱敏 env.example,补文档
2026-09-14 15:40:35 +08:00

569 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""阿里云百炼 Token Plan 个人版额度探针(仅 Python 3 标准库,零依赖)。
两种模式BAILIAN_MODE
cookie 主路:走百炼控制台 OneConsole 网关,鉴权是「控制台 Cookie」。
这是目前唯一被第三方实锤过的个人版 5 小时 / 7 天滚动窗口数据源
(端点与字段对齐 token-monitor 项目从真实账号抓取的契约)。
Cookie 随会话过期(通常几天),过期时快照标 error_kind=unauthorized。
apikey 验证路:用 sk-sp API Key 打 Token Plan 网关上的候选 usage 端点并
逐条记录响应。该路径存在性尚未证实(无 key 时网关一律先回
InvalidApiKey无法从 401 区分路径有无);若某天命中窗口字段,
快照与 cookie 模式完全同构,前端零改动。
输出契约:原子写 JSON 快照到 BAILIAN_OUT前端只读该文件绝不落 Cookie/Key
失败策略:不丢旧数据——从状态文件回填上次成功值并标 stale=true。
配置:环境变量,或 BAILIAN_PROBE_ENV 指向的 KEY=VALUE 文件(默认
/etc/bailian-probe.env权限请设 600
"""
import json
import os
import re
import sys
import time
import uuid
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
# 个人版控制台变体(对齐 token-monitor 抓取契约;国内站默认)
VARIANTS = {
"cn-personal": {
"gateway_origin": "https://bailian.console.aliyun.com",
"quota_origin": "https://bailian-cs.console.aliyun.com",
"region_id": "cn-beijing",
"commodity": "sfm_tokenplansolo_public_cn",
"console_site": "BAILIAN_ALIYUN",
"action": "BroadScopeAspnGateway",
},
"intl-personal": {
"gateway_origin": "https://modelstudio.console.alibabacloud.com",
"quota_origin": "https://bailian-singapore-cs.alibabacloud.com",
"region_id": "ap-southeast-1",
"commodity": "sfm_tokenplansolo_public_intl",
"console_site": "MODELSTUDIO_ALBABACLOUD", # 官方契约的历史拼写,保留
"action": "IntlBroadScopeAspnGateway",
},
}
PLAN_LABELS = {"lite": "Lite", "standard": "Standard", "pro": "Pro", "max": "Max"}
API_USAGE = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage"
API_SUBSCRIPTION = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription"
API_QUOTA_CONFIG = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config"
# 只读列表reset-card/use 是真正消耗卡的写操作,探针永远不得调用
API_RESET_CARDS = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/reset-card/list"
# apikey 模式的候选端点(存在性未证实,逐个探测并记录,命中即出图)
APIKEY_CANDIDATES = [
"{base}/compatible-mode/v1/usage",
"{base}/compatible-mode/v1/tokenplan/usage",
"{base}/compatible-mode/v1/subscription/stats",
"https://dashscope.aliyuncs.com/api/v1/tokenplan/subscription/stats",
]
def now_iso():
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def load_env_file(path):
"""读 KEY=VALUE 配置(不覆盖已有环境变量)。"""
if not path or not os.path.exists(path):
return
with open(path, "r", encoding="utf-8-sig") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
class ProbeError(Exception):
"""kind: unauthorized凭证失效需人工重贴/ unavailable临时不可用"""
def __init__(self, kind, msg, diag=None):
super().__init__(msg)
self.kind = kind
self.diag = diag
# --------------------------------------------------------------------------
# 通用小工具:数值 / 时间 / 全树大小写不敏感取值
# --------------------------------------------------------------------------
def expand_embedded(value):
"""网关常把 JSON 再字符串套一层递归展开token-monitor 的实锤教训)。"""
if isinstance(value, str):
s = value.strip()
if s.startswith("{") or s.startswith("["):
try:
return expand_embedded(json.loads(s))
except Exception:
return value
return value
if isinstance(value, list):
return [expand_embedded(v) for v in value]
if isinstance(value, dict):
return {k: expand_embedded(v) for k, v in value.items()}
return value
def find_key(value, name):
"""全树、大小写不敏感找第一个非空值(网关字段拼写随变体/版本漂移)。"""
wanted = name.lower()
if isinstance(value, dict):
for k, v in value.items():
if k.lower() == wanted and v not in (None, "", []):
return v
for v in value.values():
got = find_key(v, name)
if got is not None:
return got
elif isinstance(value, list):
for item in value:
got = find_key(item, name)
if got is not None:
return got
return None
def to_number(v):
try:
n = float(v)
return n if n == n else None
except (TypeError, ValueError):
return None
def to_int_or_none(v):
n = to_number(v)
return int(n) if n is not None else None
def to_iso(v):
"""epoch 秒/毫秒/无时区字符串(按东八区钉死)统一转 ISO防 8 小时偏移。"""
n = to_number(v)
if n is not None and n > 0:
ms = n if n >= 1e12 else n * 1000
return datetime.fromtimestamp(ms / 1000, timezone.utc).isoformat()
if isinstance(v, str):
s = v.strip().replace(" ", "T")
if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", s) and "+" not in s and not s.endswith("Z"):
s += "+08:00" # 控制台裸时间戳按北京时间解
try:
return datetime.fromisoformat(s).astimezone(timezone.utc).isoformat()
except ValueError:
return None
return None
def pct_from_ratio(v):
"""网关口径为 0~1 占比;>1 时视为已是百分数,避免再放大 100 倍。"""
n = to_number(v)
if n is None:
return None
if n > 1:
return round(min(max(n, 0), 100), 1)
return round(min(max(n, 0), 1) * 100, 1)
def http(url, method="GET", headers=None, data=None, timeout=20):
req = urllib.request.Request(url, method=method, data=data, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def cookie_value(cookie_header, name):
for part in (cookie_header or "").split(";"):
k, _, v = part.strip().partition("=")
if k == name:
return v.strip()
return ""
# --------------------------------------------------------------------------
# 控制台网关cookie 模式)
# --------------------------------------------------------------------------
def console_headers(cookie, variant):
h = {
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": cookie,
"Origin": variant["gateway_origin"],
"Referer": variant["gateway_origin"] + "/",
"User-Agent": UA,
"X-Requested-With": "XMLHttpRequest",
}
csrf = cookie_value(cookie, "login_aliyunid_csrf") or cookie_value(cookie, "csrf")
if csrf:
h["x-xsrf-token"] = csrf
h["x-csrf-token"] = csrf
return h
def resolve_sec_token(cookie, variant):
"""best-effort 解析 sec_tokenJSON 端点 → cookie 兜底;拿不到不算错。"""
try:
status, text = http(variant["gateway_origin"] + "/tool/user/info.json",
headers={"Accept": "application/json", "Cookie": cookie,
"User-Agent": UA,
"Referer": variant["gateway_origin"] + "/"},
timeout=10)
if status == 200:
got = find_key(expand_embedded(json.loads(text)), "secToken")
if got:
return str(got)
except Exception:
pass
return cookie_value(cookie, "sec_token")
def classify_body(payload, text):
"""网关用 HTTP 200 装一切错误:登录失效与工作空间未授权必须分开归因。"""
low = (text or "").lower()
if "<html" in low and "login" in low:
raise ProbeError("unauthorized", "控制台会话已失效(返回登录页),请重新粘贴 Cookie")
err_code = find_key(payload, "errorCode") or ""
failed = (payload.get("success") is False or payload.get("Success") is False
or bool(err_code))
if failed:
msg = str(find_key(payload, "errorMsg") or find_key(payload, "message") or err_code)
joined = f"{err_code} {msg}".lower()
if "workspace" in joined:
# 工作空间授权问题 ≠ Cookie 失效,归因错了会诱导用户白换 Cookie
raise ProbeError("unavailable", f"工作空间未授权Cookie 本身有效):{err_code or msg}")
if any(k in joined for k in ("notlogined", "needlogin", "tokenerror",
"unauthorized", "forbidden", "过期")):
raise ProbeError("unauthorized", f"鉴权失败:{err_code or msg}")
raise ProbeError("unavailable", f"网关错误:{err_code or msg}")
def fetch_personal_api(cookie, variant, api, sec_token, data_params):
cornerstone = {
"feTraceId": str(uuid.uuid4()),
"feURL": variant["gateway_origin"] + "/" ,
"protocol": "V2", "console": "ONE_CONSOLE", "productCode": "p_efm",
"switchUserType": 3,
"domain": urllib.parse.urlsplit(variant["gateway_origin"]).netloc,
"consoleSite": variant["console_site"],
"userNickName": "", "userPrincipalName": "", "xsp_lang": "zh-CN",
}
anon = cookie_value(cookie, "cna")
if anon:
cornerstone["X-Anonymous-Id"] = anon
# 刻意不带 switchAgent会把请求钉死在抓取账号的工作空间上
params = {"Api": api, "V": "1.0",
"Data": dict(data_params, cornerstoneParam=cornerstone)}
body = {
"product": "sfm_bailian", "action": variant["action"],
"region": variant["region_id"], "language": "zh-CN",
"params": json.dumps(params, ensure_ascii=False),
}
if sec_token:
body["sec_token"] = sec_token
qs = urllib.parse.urlencode({"action": variant["action"], "product": "sfm_bailian",
"api": api, "_v": "undefined"})
url = variant["quota_origin"] + "/data/api.json?" + qs
status, text = http(url, "POST", console_headers(cookie, variant),
urllib.parse.urlencode(body).encode())
if status in (401, 403):
raise ProbeError("unauthorized", f"HTTP {status}Cookie 失效")
if status != 200:
raise ProbeError("unavailable", f"HTTP {status}")
try:
payload = expand_embedded(json.loads(text))
except Exception:
raise ProbeError("unavailable", "网关返回非 JSON")
classify_body(payload, text)
return payload
def build_windows(five_raw, week_raw, five_reset_raw, week_reset_raw, totals):
"""两条窗口的共用组装cookie / apikey 模式同构输出)。"""
windows = []
five_pct = pct_from_ratio(five_raw)
week_pct = pct_from_ratio(week_raw)
if five_pct is not None:
ft = to_number(totals.get("five_hour") or totals.get("fiveHour"))
windows.append({
"id": "5h", "label": "5 小时", "used_percent": five_pct,
"total": ft,
"remaining": round(ft * (100 - five_pct) / 100, 1) if ft else None,
"reset_at": to_iso(five_reset_raw),
})
if week_pct is not None:
wt = to_number(totals.get("weekly"))
windows.append({
"id": "weekly", "label": "7 天", "used_percent": week_pct,
"total": wt,
"remaining": round(wt * (100 - week_pct) / 100, 1) if wt else None,
"reset_at": to_iso(week_reset_raw),
})
return windows
def probe_cookie_mode(variant_id):
cookie = os.environ.get("BAILIAN_COOKIE", "")
if not cookie:
raise ProbeError("unauthorized", "未配置 BAILIAN_COOKIE")
variant = VARIANTS[variant_id]
sec = resolve_sec_token(cookie, variant)
# usage 网关偶发 200 但不带窗口字段:官方行为,最多 3 次(对齐上游实现)
usage = None
for _ in range(3):
usage = fetch_personal_api(cookie, variant, API_USAGE, sec, {})
if find_key(usage, "per5HourPercentage") or find_key(usage, "per1WeekPercentage"):
break
time.sleep(0.4)
five = find_key(usage, "per5HourPercentage")
week = find_key(usage, "per1WeekPercentage")
if five is None and week is None:
raise ProbeError("unavailable", "网关未返回滚动窗口字段")
# 订阅档位与配额上限best-effort丢只丢绝对值不影响百分比
spec_code, totals, subscription = "", {}, None
try:
sub = fetch_personal_api(cookie, variant, API_SUBSCRIPTION, sec,
{"commodityCode": variant["commodity"]})
spec_code = str(find_key(sub, "specCode") or find_key(sub, "spec_code") or "").lower()
subscription = {
"status": str(find_key(sub, "status") or "").upper() or None,
"remaining_days": to_int_or_none(find_key(sub, "remainingDays")),
"start_at": to_iso(find_key(sub, "startTime")),
"end_at": to_iso(find_key(sub, "endTime")),
"auto_renew": bool(find_key(sub, "autoRenewFlag")) if find_key(sub, "autoRenewFlag") is not None else None,
}
except ProbeError:
pass
try:
qc = fetch_personal_api(cookie, variant, API_QUOTA_CONFIG, sec, {})
if spec_code:
node = find_key(qc, spec_code)
if isinstance(node, dict):
totals = node
except ProbeError:
pass
windows = build_windows(five, week,
find_key(usage, "per5HourResetTime"),
find_key(usage, "per1WeekResetTime"), totals)
reset_cards = fetch_reset_cards(cookie, variant, sec)
diag = {"usage_keys": sorted(usage.keys()) if isinstance(usage, dict) else [],
"quota_totals_found": bool(totals),
"reset_cards_found": len(reset_cards)}
return windows, PLAN_LABELS.get(spec_code, spec_code or "Personal"), diag, reset_cards, subscription
# 重置卡类型 → 中文用途(目前控制台只有 1W 一种,未知类型保留原码)
RESET_CARD_LABELS = {"RESET_1W": "7 天窗口重置卡"}
def extract_card_list(payload):
"""reset-card/list 的业务数据是嵌了三层 data 的数组,按结构特征(含 cardNo定位
比写死路径更抗网关版本漂移。"""
found = []
def walk(o):
if isinstance(o, list):
if o and all(isinstance(x, dict) and "cardNo" in x for x in o):
found.append(o)
for x in o:
walk(x)
elif isinstance(o, dict):
for x in o.values():
walk(x)
walk(payload)
return found[0] if found else []
def fetch_reset_cards(cookie, variant, sec_token):
"""best-effort卡列表是附属信息接口异常绝不影响主用量快照。"""
try:
raw = fetch_personal_api(cookie, variant, API_RESET_CARDS, sec_token, {})
cards = []
for c in extract_card_list(raw):
ctype = str(c.get("cardType") or "")
cards.append({
"type": ctype,
"label": RESET_CARD_LABELS.get(ctype, ctype or "重置卡"),
"card_no": str(c.get("cardNo") or ""),
"effective_at": to_iso(c.get("effectiveAt")),
"expires_at": to_iso(c.get("expiresAt")),
})
return cards
except Exception:
return []
# --------------------------------------------------------------------------
# API Key 候选端点apikey 模式,验证性质)
# --------------------------------------------------------------------------
def probe_apikey_mode():
key = os.environ.get("BAILIAN_API_KEY", "")
base = os.environ.get("BAILIAN_BASE_URL",
"https://token-plan.cn-beijing.maas.aliyuncs.com")
if not key:
raise ProbeError("unauthorized", "未配置 BAILIAN_API_KEY")
results, windows = [], []
for tpl in APIKEY_CANDIDATES:
url = tpl.format(base=base.rstrip("/"))
try:
status, text = http(url, headers={"Authorization": "Bearer " + key,
"Accept": "application/json",
"User-Agent": UA}, timeout=15)
except Exception as e:
results.append({"url": url, "error": str(e)[:200]})
continue
entry = {"url": url, "status": status, "excerpt": text[:300]}
try:
payload = expand_embedded(json.loads(text))
f = find_key(payload, "per5HourPercentage")
w = find_key(payload, "per1WeekPercentage")
entry["window_like"] = f is not None or w is not None
if entry["window_like"] and not windows:
windows = build_windows(f, w,
find_key(payload, "per5HourResetTime"),
find_key(payload, "per1WeekResetTime"), {})
except Exception:
entry["window_like"] = False
results.append(entry)
if not windows:
# 全部未命中:探测记录仍要进快照 diagnostics这就是本模式的全部意义
raise ProbeError("unavailable", "候选 usage 端点均未返回窗口字段(见 diagnostics",
diag={"apikey_probe": results})
plan = os.environ.get("BAILIAN_PLAN_NAME", "Personal")
return windows, plan, {"apikey_probe": results}
# --------------------------------------------------------------------------
# 快照与状态
# --------------------------------------------------------------------------
def read_json(path):
try:
# utf-8-sig容忍 Windows 记事本/PowerShell 写出的带 BOM 文件
with open(path, "r", encoding="utf-8-sig") as f:
return json.load(f)
except Exception:
return None
def atomic_write(path, obj):
parent = os.path.dirname(os.path.abspath(path))
os.makedirs(parent, exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, indent=1)
os.replace(tmp, path)
def zero_without_rollover(new_windows, last_windows):
"""窗口没滚出reset_at 未变早)却从高位掉回 0%:语义可疑,只标记不改数。"""
flags = []
for w in new_windows:
prev = next((x for x in last_windows or [] if x.get("id") == w.get("id")), None)
if (w.get("used_percent") == 0 and prev and (prev.get("used_percent") or 0) >= 5
and w.get("reset_at") and w.get("reset_at") == prev.get("reset_at")):
# 字符串直接比较会因时区偏移不同而失真,按 datetime 比较
try:
if datetime.fromisoformat(w["reset_at"]) > datetime.now(timezone.utc):
flags.append(w["id"])
except ValueError:
pass
return flags
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
load_env_file(os.environ.get("BAILIAN_PROBE_ENV", "/etc/bailian-probe.env"))
out = os.environ.get("BAILIAN_OUT",
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"bailian.json"))
state_path = os.environ.get("BAILIAN_STATE", out + ".state.json")
log_path = os.environ.get("BAILIAN_LOG", out + ".probe.jsonl")
mode = os.environ.get("BAILIAN_MODE", "cookie")
variant_id = os.environ.get("BAILIAN_VARIANT", "cn-personal")
if variant_id not in VARIANTS:
variant_id = "cn-personal"
snapshot = {"schema": 1,
"source": {"cookie": "console-cookie", "apikey": "apikey-probe"}.get(mode, mode),
"plan": "personal", "probed_at": now_iso(),
"ok": False, "stale": False, "last_error": None,
"error_kind": None, "plan_level": None, "windows": [],
"credits": None, "diagnostics": None, "reset_cards": [],
"subscription": None}
last = read_json(state_path) or {}
try:
if mode == "apikey":
windows, plan, diag = probe_apikey_mode()
reset_cards = []
subscription = None
else:
windows, plan, diag, reset_cards, subscription = probe_cookie_mode(variant_id)
flags = zero_without_rollover(windows, last.get("windows"))
if flags:
diag["zero_without_rollover"] = flags
snapshot.update({"ok": True, "plan_level": plan, "windows": windows,
"diagnostics": diag, "reset_cards": reset_cards,
"subscription": subscription})
# credits 只报 7 天池(两个窗口是两个池子,相加无意义)
wk = next((w for w in windows if w["id"] == "weekly"), None)
if wk and wk.get("total"):
snapshot["credits"] = {"total": wk["total"],
"used": round(wk["total"] - (wk["remaining"] or 0), 1),
"remaining": wk["remaining"], "unit": "Credits"}
atomic_write(state_path, snapshot)
except ProbeError as e:
snapshot["last_error"], snapshot["error_kind"] = str(e), e.kind
if e.diag:
snapshot["diagnostics"] = e.diag
except Exception as e: # 网络抖动等:也走 stale 回填,不让前端白屏
snapshot["last_error"], snapshot["error_kind"] = f"{type(e).__name__}: {e}", "unavailable"
if not snapshot["ok"] and last.get("windows"):
# 失败保留上次成功值(与 Sub2API CN 探测同策略),显式标 stale
snapshot["windows"] = last["windows"]
snapshot["plan_level"] = last.get("plan_level") or snapshot["plan_level"]
snapshot["credits"] = last.get("credits")
snapshot["reset_cards"] = last.get("reset_cards") or []
snapshot["subscription"] = last.get("subscription")
snapshot["stale"] = True
snapshot["last_ok_at"] = last.get("probed_at")
try:
os.makedirs(os.path.dirname(os.path.abspath(log_path)), exist_ok=True)
with open(log_path, "a", encoding="utf-8") as f:
f.write(json.dumps({"ts": snapshot["probed_at"], "mode": mode,
"ok": snapshot["ok"], "kind": snapshot["error_kind"],
"err": (snapshot["last_error"] or "")[:200]},
ensure_ascii=False) + "\n")
except Exception:
pass
atomic_write(out, snapshot)
print(json.dumps({"ok": snapshot["ok"], "stale": snapshot["stale"],
"windows": len(snapshot["windows"]),
"error": snapshot["last_error"]}, ensure_ascii=False))
return 0 if (snapshot["ok"] or snapshot["stale"]) else 1
if __name__ == "__main__":
sys.exit(main())