#!/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_token:JSON 端点 → 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 "= 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())