Skip to content

查余额这种事,五分钟一次就够了 ​

我习惯把 API 余额挂在状态栏或者 shell 提示符旁边。问题在于,这些东西刷新得太勤了。状态栏每次重绘都去请求一次余额接口,一天下来几百次请求,就为了看一个几乎不变的数字。

而且 DeepSeek 的余额接口不需要那么实时。余额是按用量扣的,我一天也用不了几块钱,五分钟前的数字和现在的数字,差不了多少。

所以我先定下的规则是:别查太勤。

输出就一行 ​

txt
$ uv run --no-project deepseek_usage.py
DS余额:12.34

没有表格,没有单位说明,没有更新时间。因为它的用途就是嵌进别的输出里,多一个字都是负担。

脚本本身是单文件,顶部带 PEP-723 元数据,没有任何第三方依赖:

python
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///

uv run --no-project 直接跑,不用建虚拟环境,不用装包。这种东西我连一个目录都不想为它建。

完整代码 ​

整个文件不长,先放完,后面挑几段说。

python
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""查询并打印 DeepSeek 账户余额。

余额来自 https://api.deepseek.com/user/balance,
API key 从 $SECRETS49/.env 的 deepseek-key 读取。
输出单行:DS余额:12.34

查询结果缓存在系统临时目录,5 分钟内重复运行不再请求接口。
"""

import json
import os
import sys
import tempfile
import time
import urllib.request

BALANCE_URL = "https://api.deepseek.com/user/balance"
REQUEST_TIMEOUT_SECONDS = 10

CACHE_PATH = os.path.join(tempfile.gettempdir(), "deepseek_usage_cache.json")
CACHE_TTL_SECONDS = 300

SECRETS_DIR_ENV = "SECRETS49"
API_KEY_NAME = "deepseek-key"


def read_api_key() -> str:
    """从 $SECRETS49/.env 读取 deepseek-key,缺失时退出并说明原因。"""
    secrets_dir = os.environ.get(SECRETS_DIR_ENV)
    if not secrets_dir:
        sys.exit(f"环境变量 {SECRETS_DIR_ENV} 未设置")

    env_file = os.path.join(secrets_dir, ".env")
    try:
        with open(env_file, encoding="utf-8") as file:
            lines = file.read().splitlines()
    except OSError:
        sys.exit(f"无法读取 {env_file}")

    for line in lines:
        name, separator, value = line.partition("=")
        if separator and name.strip() == API_KEY_NAME:
            return value.strip().strip("'\"")
    sys.exit(f"{env_file} 里没有 {API_KEY_NAME}")


def fetch_balance(api_key: str) -> dict:
    request = urllib.request.Request(
        BALANCE_URL,
        headers={
            "Accept": "application/json",
            "Authorization": f"Bearer {api_key}",
        },
    )
    try:
        with urllib.request.urlopen(
            request, timeout=REQUEST_TIMEOUT_SECONDS
        ) as response:
            return json.load(response)
    except (OSError, ValueError):
        sys.exit("查询 DeepSeek 余额失败:网络或响应异常")


def load_cached_payload() -> dict | None:
    """返回 5 分钟内的缓存响应,没有或已过期时返回 None。"""
    try:
        with open(CACHE_PATH, encoding="utf-8") as file:
            cached = json.load(file)
    except (OSError, ValueError):
        return None

    if not isinstance(cached, dict):
        return None
    fetched_at = cached.get("fetched_at")
    if not isinstance(fetched_at, (int, float)):
        return None
    if time.time() - fetched_at > CACHE_TTL_SECONDS:
        return None

    payload = cached.get("payload")
    return payload if isinstance(payload, dict) else None


def save_cached_payload(payload: dict) -> None:
    try:
        with open(CACHE_PATH, "w", encoding="utf-8") as file:
            json.dump({"fetched_at": time.time(), "payload": payload}, file)
    except OSError:
        print(f"警告:无法写入缓存 {CACHE_PATH}", file=sys.stderr)


def format_balance(payload: dict) -> str:
    if payload.get("is_available") is False:
        return "DS余额:不可用"

    balance_infos = payload.get("balance_infos")
    if not isinstance(balance_infos, list):
        return ""
    infos = [item for item in balance_infos if isinstance(item, dict)]
    if not infos:
        return ""

    info = next((item for item in infos if item.get("currency") == "CNY"), infos[0])
    return f"DS余额:{format_amount(info.get('total_balance'))}"


def format_amount(value: object) -> str:
    try:
        amount = float(value)  # type: ignore[arg-type]
    except (TypeError, ValueError):
        amount = 0.0
    return f"{amount:.2f}".rstrip("0").rstrip(".")


def main() -> None:
    payload = load_cached_payload()
    is_cached = payload is not None
    if payload is None:
        payload = fetch_balance(read_api_key())

    text = format_balance(payload)
    if not text:
        sys.exit("响应里没有余额数据")

    if not is_cached:
        save_cached_payload(payload)
    print(text)


if __name__ == "__main__":
    main()

缓存放在临时目录 ​

python
CACHE_PATH = os.path.join(tempfile.gettempdir(), "deepseek_usage_cache.json")
CACHE_TTL_SECONDS = 300

五分钟。放在系统临时目录,系统清理临时文件时顺手带走,不用我管。

读缓存的时候,任何一点不对都当成没缓存:

python
def load_cached_payload() -> dict | None:
    try:
        with open(CACHE_PATH, encoding="utf-8") as file:
            cached = json.load(file)
    except (OSError, ValueError):
        return None

    if not isinstance(cached, dict):
        return None
    fetched_at = cached.get("fetched_at")
    if not isinstance(fetched_at, (int, float)):
        return None
    if time.time() - fetched_at > CACHE_TTL_SECONDS:
        return None

    payload = cached.get("payload")
    return payload if isinstance(payload, dict) else None

文件坏了、JSON 被截断、fetched_at 是个字符串、缓存里没有 payload——全都返回 None,老老实实去请求接口。缓存坏了就当没有,重新请求一次。

写缓存失败则只警告,不影响结果:

python
    except OSError:
        print(f"警告:无法写入缓存 {CACHE_PATH}", file=sys.stderr)

注意是 stderr。标准输出要留给那一行余额,嵌到别的脚本里时不能被警告污染。

金额末尾的零全部去掉 ​

format_amount 这段很短,但我挺喜欢:

python
def format_amount(value: object) -> str:
    try:
        amount = float(value)  # type: ignore[arg-type]
    except (TypeError, ValueError):
        amount = 0.0
    return f"{amount:.2f}".rstrip("0").rstrip(".")

先格式化成两位小数,再去掉末尾的零和小数点。

  • 12.34 → 12.34
  • 12.30 → 12.3
  • 12.00 → 12

余额接口给的是字符串,"12.300000" 这种写法我也见过。统一成两位小数再裁一遍,输出就干净了。

rstrip(".") 那一句不能省。12.00 先变成 12.,再去掉点才是 12。少了它就会输出 12.,看着像话没说完。

账户不可用时别显示 0 ​

python
def format_balance(payload: dict) -> str:
    if payload.get("is_available") is False:
        return "DS余额:不可用"
    ...

is_available 为 false 时,直接说「不可用」。这时候接口给的余额可能是 0,也可能缺字段,但「不可用」比「0」更接近事实——账户可能被停了,余额数字没有意义。

币种优先选人民币:

python
    info = next((item for item in infos if item.get("currency") == "CNY"), infos[0])

没有 CNY 就取第一个。我自己的账户只有人民币,但接口是通用的,多写一行不亏。

密钥只从一个地方读 ​

python
SECRETS_DIR_ENV = "SECRETS49"
API_KEY_NAME = "deepseek-key"

从 $SECRETS49/.env 里读 deepseek-key。没设环境变量、文件读不了、键不存在,三种情况都 sys.exit 带一句人话:

txt
环境变量 SECRETS49 未设置
无法读取 /path/to/.env
/path/to/.env 里没有 deepseek-key

不输出 traceback,不打印密钥,不尝试从别的地方找。我所有小脚本的密钥都在这一个文件里,这个约定越简单越好。

挂在状态栏里 ​

状态栏调用它,拿到那一行,塞进底部。五分钟内连续刷新都是缓存,几十毫秒就返回;过了五分钟,下一次刷新才真正请求。

Nine and AI — Licensed under the MIT License