diff --git a/AGENTS.md b/AGENTS.md index 324dc7f..a5b3390 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # CLAUDE.md -## 项目概述 +## Project overview -被访问时实时抓取 OpenCode Go 工作区页面、解析用量并返回 JSON 的 HTTPS API(FastAPI)。支持多个命名账号,所有逻辑集中在 `opencode_go_usage_api/` 包内,CLI 入口为 `opencode_go_usage_api/cli.py`。 +An HTTPS API (FastAPI) that live-scrapes the OpenCode Go workspace page on each request, parses usage, and returns JSON. It supports multiple named accounts; all logic lives in the `opencode_go_usage_api/` package, and the CLI entrypoint is `opencode_go_usage_api/cli.py`. -## 常用命令 +## Common commands ```bash uv sync @@ -12,21 +12,21 @@ uv run uvicorn opencode_go_usage_api:app --reload uv run opencode-go-usage-api uv run pytest ./run.sh -./gen-cert.sh <公网IP> +./gen-cert.sh curl -k https://127.0.0.1:/health curl -k -H "Authorization: Bearer " https://127.0.0.1:/usage curl -k -H "Authorization: Bearer " https://127.0.0.1:/usage/ ``` -## 配置 +## Configuration -服务固定读取当前工作目录下的 `config.toml`,模板见 `config.example.toml`。配置包含 API Token、监听/TLS、全局抓取与响应设置,以及 `[accounts.]` 多账号凭据。配置在启动时严格校验,修改后需重启。 +The service always reads `config.toml` from the current working directory; see `config.example.toml` for a template. The config holds the API token, listen/TLS settings, global fetch and response settings, and the `[accounts.]` multi-account credentials. It is validated strictly at startup, and a restart is required after edits. -## 部署 +## Deployment -服务文件 `opencode-go-usage-api.service`,部署步骤见 `README.md`。 +The systemd unit is `opencode-go-usage-api.service`; deployment steps are in `README.md`. -## 隐私保护 +## Privacy protection -- 测试用例中不允许出现个人信息,账户标识、customerID、subscriptionID、邮箱等必须替换为虚构值。 -- `config.toml` 含真实凭据,不得提交,其具体内容不允许以任何形式出现在 Agent 的上下文中。 +- No personal information may appear in test cases; account identifiers, customerID, subscriptionID, emails, etc. must be replaced with fictitious values. +- `config.toml` contains real credentials and must never be committed; its actual contents must not appear in the agent's context in any form. diff --git a/README.md b/README.md index f630149..135e791 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,50 @@ -# OpenCode Go 用量 API +# OpenCode Go Usage API -被访问时实时抓取指定 OpenCode Go 工作区页面,解析用量数据并返回 JSON。支持在一个服务中配置多个 OpenCode Go 账号,供 [CC Switch](https://ccswitch.io/) 的用量查询功能使用。 +Live-scrapes the specified OpenCode Go workspace page on each request, parses the usage data, and returns it as JSON. Multiple OpenCode Go accounts can be configured in a single service, for use with the usage-query feature of [CC Switch](https://ccswitch.io/). -当 CC Switch 官方支持了 OpenCode Go 的用量查询或 OpenCode 提供了 JSON 格式的用量查询接口后,本项目将停止维护。相关 issue 和 PR:[#2260](https://github.com/farion1231/cc-switch/issues/2260),[#3606](https://github.com/farion1231/cc-switch/pull/3606) +This project will stop being maintained once CC Switch officially supports OpenCode Go usage queries, or OpenCode provides a JSON usage-query API. Related issue and PR: [#2260](https://github.com/farion1231/cc-switch/issues/2260), [#3606](https://github.com/farion1231/cc-switch/pull/3606) ## API -### 查询用量 +### Query usage -- `GET /usage`:查询 `config.toml` 中指定的默认账号。 -- `GET /usage/{account_id}`:查询指定账号,例如 `/usage/backup`。 +- `GET /usage`: queries the default account specified in `config.toml`. +- `GET /usage/{account_id}`: queries a specific account, e.g. `/usage/backup`. -两个接口都需要请求头 `Authorization: Bearer `,成功响应格式一致: +Both endpoints require the header `Authorization: Bearer `. The response format is: ```json { "success": true, "reason": "", - "data": "滚动 0% (5h) | 周 7% (3d16h) | 月 3% (29d22h)" + "data": "Rolling 0% (5h) | Weekly 7% (3d16h) | Monthly 3% (29d22h)" } ``` -- `success`:解析出至少一组用量时为 `true`。 -- `reason`:抓取或解析失败时说明原因。 -- `data`:用量与重置倒计时,可通过配置模板自定义。 +- `success`: `true` when at least one usage group was parsed. +- `reason`: explains the reason when fetching or parsing fails. +- `data`: usage and reset countdown, customizable via the config template. -已知账号的凭据失效、抓取失败和解析失败仍返回 HTTP 200,并通过 `success:false` 表示。未知账号返回 HTTP 404,鉴权失败返回 HTTP 401。 +Expired credentials, fetch failures, and parse failures for a known account still return HTTP 200, indicated by `success:false`. Unknown accounts return HTTP 404, and auth failures return HTTP 401. -### 健康检查 +### Health check -`GET /health` 免鉴权,返回 `{"status":"ok"}`。该接口只检查服务存活,不抓取账号页面。 +`GET /health` requires no auth and returns `{"status":"ok"}`. This endpoint only checks that the service is alive; it does not fetch any account page. -## 配置 +## Configuration -服务固定读取当前工作目录下的 `config.toml`。复制示例并限制文件权限: +The service always reads `config.toml` from the current working directory. Copy the example and restrict file permissions: ```bash cp config.example.toml config.toml chmod 600 config.toml ``` -完整示例: +Full example: ```toml [server] -api_token = "建议使用 openssl rand -hex 32 生成一个强随机值" +api_token = "generate a strong random value, e.g. with openssl rand -hex 32" host = "0.0.0.0" port = 18443 ssl_certfile = "certs/cert.pem" @@ -53,76 +53,76 @@ ssl_keyfile = "certs/key.pem" [fetch] timeout = 10 retries = 1 -locale = "zh" +locale = "en" user_agent = "Mozilla/5.0 ..." [response] -data_template = "滚动 {rolling_percent}% ({rolling_reset}) | 周 {weekly_percent}% ({weekly_reset}) | 月 {monthly_percent}% ({monthly_reset})" +data_template = "Rolling {rolling_percent}% ({rolling_reset}) | Weekly {weekly_percent}% ({weekly_reset}) | Monthly {monthly_percent}% ({monthly_reset})" [account] default = "main" [accounts.main] -auth_cookie = "主账号的 auth cookie 值" +auth_cookie = "the main account's auth cookie value" workspace_id = "wrk_main" [accounts.backup] -auth_cookie = "备用账号的 auth cookie 值" +auth_cookie = "the backup account's auth cookie value" workspace_id = "wrk_backup" ``` -账号 ID 支持 1–64 位大小写字母、数字、`_`、`-`,首位必须是字母或数字,大小写敏感。 +Account IDs support 1-64 letters (upper and lower case), digits, `_`, and `-`; the first character must be a letter or digit, and they are case-sensitive. -程序启动时会校验配置。修改配置文件后需要重启服务使新配置生效。 +The config is validated at startup. After modifying the config file, restart the service for the changes to take effect. -### 配置项 +### Configuration options -| 配置 | 默认值 | 说明 | -| ------------------------------------- | ------------- | ------------------------------- | -| `server.api_token` | 无 | API 访问密钥,必填 | -| `server.host` | `0.0.0.0` | 监听地址 | -| `server.port` | `18443` | 监听端口 | -| `server.ssl_certfile` / `ssl_keyfile` | 空 | 必须同时填写;均为空时使用 HTTP | -| `fetch.timeout` | `10` | 单次抓取超时秒数 | -| `fetch.retries` | `1` | 网络异常后的重试次数 | -| `fetch.locale` | `zh` | OpenCode 的 `oc_locale` cookie | -| `fetch.user_agent` | 内置浏览器 UA | 上游请求的 User-Agent | -| `response.data_template` | 内置模板 | 响应 `data` 字段模板 | -| `account.default` | 无 | 默认账号 ID,必填 | -| `accounts..auth_cookie` | 无 | 该账号的原始 `auth` cookie 值 | -| `accounts..workspace_id` | 无 | 该账号对应的工作区 ID | +| Config | Default | Description | +| --------------------------------------- | -------------- | ------------------------------------ | +| `server.api_token` | none | API access token, required | +| `server.host` | `0.0.0.0` | Listen address | +| `server.port` | `18443` | Listen port | +| `server.ssl_certfile` / `ssl_keyfile` | empty | Must be set together; HTTP when both empty | +| `fetch.timeout` | `10` | Single fetch timeout in seconds | +| `fetch.retries` | `1` | Retries after a network error | +| `fetch.locale` | `en` | The OpenCode `oc_locale` cookie | +| `fetch.user_agent` | built-in browser UA | User-Agent for upstream requests | +| `response.data_template` | built-in template | Template for the response `data` field | +| `account.default` | none | Default account ID, required | +| `accounts..auth_cookie` | none | The account's raw `auth` cookie value | +| `accounts..workspace_id` | none | The account's workspace ID | -- 工作区 ID:`https://opencode.ai/workspace/<这里>/go` -- Cookie 获取:在浏览器中打开 `https://opencode.ai/workspace/wrk_XXX/go`,通过开发者工具(`F12`)查看 +- Workspace ID: `https://opencode.ai/workspace//go` +- Getting the cookie: open `https://opencode.ai/workspace/wrk_XXX/go` in a browser and inspect it with the developer tools (`F12`) -### 自定义 data 格式 +### Custom data format -模板占位符由分组和字段组成,格式为 `{<分组>_<字段>}`。有三种分组: +Template placeholders consist of a group and a field, formatted as `{_}`. There are three groups: -| 分组 | 含义 | -| --------- | -------- | -| `rolling` | 滚动用量 | -| `weekly` | 每周用量 | -| `monthly` | 每月用量 | +| Group | Meaning | +| --------- | --------------- | +| `rolling` | Rolling usage | +| `weekly` | Weekly usage | +| `monthly` | Monthly usage | -每个分组都支持三种字段: +Each group supports three fields: -| 字段 | 含义 | -| --------- | ------------ | -| `percent` | 已用百分比 | -| `reset` | 距重置倒计时 | -| `status` | 状态文本 | +| Field | Meaning | +| --------- | ------------------- | +| `percent` | Percent used | +| `reset` | Countdown to reset | +| `status` | Status text | -简洁风格配置示例: +Compact-style example: ```toml [response] data_template = "R {rolling_percent}% ({rolling_reset}) | W {weekly_percent}% ({weekly_reset}) | M {monthly_percent}% ({monthly_reset})" ``` -## 部署 +## Deployment -以下示例假设系统为 Ubuntu 24.04、项目目录为 `/opt/opencode-go-usage-api`,已安装 [uv](https://docs.astral.sh/uv/)。 +The examples below assume Ubuntu 24.04, project directory `/opt/opencode-go-usage-api`, and [uv](https://docs.astral.sh/uv/) installed. ```bash cd /opt @@ -130,18 +130,18 @@ git clone https://github.com/andywang425/opencode-go-usage-api.git cd opencode-go-usage-api uv sync cp config.example.toml config.toml -# 按需编辑 config.toml +# edit config.toml as needed chmod 600 config.toml ``` -如需自签证书: +For a self-signed certificate: ```bash chmod +x gen-cert.sh -./gen-cert.sh <公网IP> +./gen-cert.sh ``` -然后将生成的证书的路径写入 `config.toml`: +Then write the paths of the generated certificates into `config.toml`: ```toml [server] @@ -149,43 +149,43 @@ ssl_certfile = "certs/cert.pem" ssl_keyfile = "certs/key.pem" ``` -安装 systemd 服务: +Install the systemd service: ```bash chmod +x run.sh cp opencode-go-usage-api.service /etc/systemd/system/ -# 按需编辑 /etc/systemd/system/opencode-go-usage-api.service +# edit /etc/systemd/system/opencode-go-usage-api.service as needed systemctl daemon-reload systemctl enable --now opencode-go-usage-api systemctl status opencode-go-usage-api ``` -如果后续修改了配置,需重启服务使其生效: +If you later modify the config, restart the service for the changes to take effect: ```bash systemctl restart opencode-go-usage-api ``` -记得在云厂商安全组 / 防火墙放行 `server.port`(默认 `18443`)。 +Remember to allow `server.port` (default `18443`) in your cloud provider's security group / firewall. -验证服务(使用自签证书时需添加 `-k`): +Verify the service (add `-k` when using a self-signed certificate): ```bash curl -k https://127.0.0.1:18443/health -curl -k -H "Authorization: Bearer " https://127.0.0.1:18443/usage -curl -k -H "Authorization: Bearer " https://127.0.0.1:18443/usage/backup -# 看日志 +curl -k -H "Authorization: Bearer ***" https://127.0.0.1:18443/usage +curl -k -H "Authorization: Bearer ***" https://127.0.0.1:18443/usage/backup +# watch the logs journalctl -u opencode-go-usage-api -f ``` -## CC Switch 接入 +## CC Switch integration -点击配置用量查询图标,预设模板选择自定义,填入以下提取器代码: +Click the configure-usage-query icon, select Custom for the preset template, and paste in the following extractor code: ```js ({ request: { - url: "https://<公网IP>:/usage/", + url: "https://:/usage/", method: "GET", headers: { Authorization: "Bearer ", @@ -201,39 +201,39 @@ journalctl -u opencode-go-usage-api -f }); ``` -若不填 `/` 则查询默认账号的用量。如果使用自签证书,需要将证书导入运行 CC Switch 的操作系统的信任证书库。 +If you omit `/`, the default account's usage is queried. If you use a self-signed certificate, you must import the certificate into the trust store of the OS running CC Switch. -Windows 11 安装自签证书的方法:用任意方式下载 `certs/cert.pem` 到本地,将其重命名为 `cert.crt`,双击,安装证书 → 存储位置选择用户 → 将所有证书都放入下列存储,浏览 → 受信任的根证书颁发机构 → 下一步,完成。 +To install a self-signed certificate on Windows 11: download `certs/cert.pem` locally in any way, rename it to `cert.crt`, double-click it, install certificate -> choose Current User for the store location -> place all certificates in the following store, browse -> Trusted Root Certification Authorities -> Next, Finish. -## Cookie 失效 +## Cookie expiry -当接口返回以下结果时,重新登录对应 OpenCode 账号并更新其 `auth_cookie`,然后重启服务: +When the API returns the following, log back into the corresponding OpenCode account, update its `auth_cookie`, and restart the service: ```json { "success": false, - "reason": "登录凭证已失效,请重新获取 auth cookie;若 cookie 确认有效,请检查 workspace_id 是否正确", + "reason": "auth cookie has expired, please re-fetch it; if the cookie is valid, check that workspace_id is correct", "data": "" } ``` -`抓取失败:…` 表示网络或上游异常,`当前账号无 OpenCode Go 订阅` 表示该工作区没有 Go 套餐,`未能从页面解析出用量数据…` 通常表示页面结构发生变化。 +`fetch failed: ...` indicates a network or upstream issue; `this account has no OpenCode Go subscription` means the workspace has no Go plan; `failed to parse usage data from the page (the page structure may have changed)` usually means the page structure changed. -## 本地开发 +## Local development ```bash uv sync cp config.example.toml config.toml -# 编辑 config.toml 后启动 +# start after editing config.toml uv run uvicorn opencode_go_usage_api:app --reload ``` -运行测试: +Run the tests: ```bash uv run pytest ``` -## 许可证 +## License [MIT](LICENSE) diff --git a/config.example.toml b/config.example.toml index d64dc04..b33abb2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,30 +1,30 @@ -# 复制本文件为 config.toml,并确保只有服务用户可读:chmod 600 config.toml +# Copy this file to config.toml and ensure only the service user can read it: chmod 600 config.toml [server] -# 第三方请求使用 Authorization: Bearer +# Third-party requests use Authorization: Bearer api_token = "replace-with-a-strong-random-token" host = "0.0.0.0" port = 18443 -# 两项必须同时填写或同时留空;留空时使用 HTTP +# Both fields must be set together or left both empty; when empty, HTTP is used ssl_certfile = "" ssl_keyfile = "" [fetch] timeout = 10 retries = 1 -locale = "zh" +locale = "en" user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" [response] -# 留空时使用下方默认格式;未知占位符会原样保留 -data_template = "滚动 {rolling_percent}% ({rolling_reset}) | 周 {weekly_percent}% ({weekly_reset}) | 月 {monthly_percent}% ({monthly_reset})" +# Leave empty to use the default format below; unknown placeholders are preserved as-is +data_template = "Rolling {rolling_percent}% ({rolling_reset}) | Weekly {weekly_percent}% ({weekly_reset}) | Monthly {monthly_percent}% ({monthly_reset})" [account] -# 即使只配置一个账号也必须明确指定默认账号 +# The default account must be set explicitly even if you only configure one default = "main" -# 账号 ID 支持大小写字母、数字、_、-,最长 64 位,且大小写敏感 +# Account IDs allow letters, digits, _, -, up to 64 chars, and are case-sensitive [accounts.main] auth_cookie = "replace-with-main-auth-cookie" workspace_id = "wrk_main" diff --git a/gen-cert.sh b/gen-cert.sh index cf6345a..8a59700 100755 --- a/gen-cert.sh +++ b/gen-cert.sh @@ -1,15 +1,15 @@ #!/usr/bin/env bash -# 生成自签 TLS 证书,SAN 绑定服务器公网 IP,有效期 10 年。 -# 用法:./gen-cert.sh <公网IP> [输出目录] -# 例: ./gen-cert.sh 203.0.113.45 +# Generate a self-signed TLS certificate whose SAN is bound to the server's public IP, valid for 10 years. +# Usage: ./gen-cert.sh [output directory] +# Example: ./gen-cert.sh 203.0.113.45 set -euo pipefail IP="${1:-}" OUT_DIR="${2:-$(dirname "$0")/certs}" if [[ -z "$IP" ]]; then - echo "用法: $0 <公网IP> [输出目录]" >&2 - echo "例: $0 203.0.113.45" >&2 + echo "Usage: $0 [output directory]" >&2 + echo "Example: $0 203.0.113.45" >&2 exit 1 fi @@ -17,8 +17,8 @@ mkdir -p "$OUT_DIR" CERT="$OUT_DIR/cert.pem" KEY="$OUT_DIR/key.pem" -# -nodes: 私钥不加密(systemd 无人值守启动需要) -# subjectAltName=IP:: 关键,客户端按 IP 校验时需要它 +# -nodes: keep the private key unencrypted (required for unattended systemd startup) +# subjectAltName=IP:: critical, clients validate by IP and need it openssl req -x509 -newkey rsa:2048 -sha256 \ -days 3650 -nodes \ -keyout "$KEY" -out "$CERT" \ @@ -28,9 +28,9 @@ openssl req -x509 -newkey rsa:2048 -sha256 \ chmod 600 "$KEY" chmod 644 "$CERT" -echo "已生成:" -echo " 证书: $CERT" -echo " 私钥: $KEY (权限 600)" +echo "Generated:" +echo " Certificate: $CERT" +echo " Private key: $KEY (mode 600)" echo -echo "证书信息:" +echo "Certificate details:" openssl x509 -in "$CERT" -noout -subject -ext subjectAltName -dates diff --git a/opencode_go_usage_api/__init__.py b/opencode_go_usage_api/__init__.py index dc5f1a9..ac048ea 100644 --- a/opencode_go_usage_api/__init__.py +++ b/opencode_go_usage_api/__init__.py @@ -1,4 +1,4 @@ -"""OpenCode Go 多账号用量 API。""" +"""OpenCode Go multi-account usage API.""" from __future__ import annotations @@ -8,7 +8,7 @@ def __getattr__(name: str) -> Any: - """兼容 ``uvicorn opencode_go_usage_api:app``,并保持普通导入无副作用。""" + """Support ``uvicorn opencode_go_usage_api:app`` while keeping plain imports side-effect free.""" if name != "app": raise AttributeError(name) diff --git a/opencode_go_usage_api/app.py b/opencode_go_usage_api/app.py index d6c0b03..b3915a3 100644 --- a/opencode_go_usage_api/app.py +++ b/opencode_go_usage_api/app.py @@ -1,4 +1,4 @@ -"""FastAPI 应用工厂。""" +"""FastAPI application factory.""" from __future__ import annotations @@ -18,17 +18,17 @@ class UnauthorizedError(Exception): - """鉴权失败,由全局异常处理器统一返回 JSON。""" + """Auth failure, returned as JSON by the global exception handler.""" def create_app( config: AppConfig, response_builder: ResponseBuilder = build_response ) -> FastAPI: - """使用已校验配置创建应用,便于启动和测试共用同一条路径。""" + """Build the app from an already-validated config, so startup and tests share one path.""" @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: - # 每个账号一个应用生命周期内常驻的 Client,复用连接池/TLS 会话 + # One persistent Client per account for the app's lifetime, reusing connection pool / TLS session app.state.http_clients = { account_id: create_client(account, config.fetch) for account_id, account in config.accounts.items() @@ -45,8 +45,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: def require_token(authorization: str = Header(default="")) -> None: expected = f"Bearer {config.server.api_token}" - # 请求头按 latin-1 解码可能含非 ASCII 字符,而 compare_digest 不支持 - # 非 ASCII 字符串(抛 TypeError 变成 500),因此统一编码为 bytes 再比较。 + # The header is decoded as latin-1 and may contain non-ASCII bytes, which + # compare_digest does not accept (raises TypeError -> 500). Encode both + # sides as bytes so the comparison is always safe. if not hmac.compare_digest( authorization.encode("utf-8"), expected.encode("utf-8") ): @@ -71,20 +72,20 @@ def account_usage(account_id: str) -> JSONResponse: account = config.accounts.get(account_id) if account is None: return JSONResponse( - {"success": False, "reason": "账号不存在", "data": ""}, + {"success": False, "reason": "account not found", "data": ""}, status_code=404, ) return response_for(account) @app.get("/health") def health() -> dict: - """存活检查不鉴权、不抓取,也不检查上游账号状态。""" + """Liveness probe: no auth, no fetch, no upstream account check.""" return {"status": "ok"} @app.exception_handler(UnauthorizedError) def handle_unauthorized(request: Request, exc: UnauthorizedError) -> JSONResponse: return JSONResponse( - {"success": False, "reason": "未授权", "data": ""}, + {"success": False, "reason": "unauthorized", "data": ""}, status_code=401, ) diff --git a/opencode_go_usage_api/cli.py b/opencode_go_usage_api/cli.py index fc5729f..2da2c6a 100644 --- a/opencode_go_usage_api/cli.py +++ b/opencode_go_usage_api/cli.py @@ -1,4 +1,4 @@ -"""CLI 入口:读取 config.toml 并启动 API。""" +"""CLI entrypoint: read config.toml and start the API.""" from __future__ import annotations @@ -14,7 +14,7 @@ def main() -> None: try: config = load_config() except ConfigError as exc: - print(f"配置错误:{exc}", file=sys.stderr) + print(f"configuration error: {exc}", file=sys.stderr) raise SystemExit(1) from exc uvicorn.run( diff --git a/opencode_go_usage_api/config.py b/opencode_go_usage_api/config.py index 6b6b73a..18fcdfd 100644 --- a/opencode_go_usage_api/config.py +++ b/opencode_go_usage_api/config.py @@ -1,4 +1,4 @@ -"""从工作目录下的 config.toml 加载并校验服务配置。""" +"""Load and validate the service config from config.toml in the working directory.""" from __future__ import annotations @@ -25,7 +25,7 @@ class ConfigError(ValueError): - """配置文件缺失、格式错误或字段校验失败。""" + """Config file missing, malformed, or failing field validation.""" @dataclass(frozen=True) @@ -74,7 +74,7 @@ class AppConfig: def _reject_unknown(table: Mapping[str, Any], allowed: set[str], context: str) -> None: unknown = sorted(set(table) - allowed) if unknown: - raise ConfigError(f"{context} 包含未知字段:{', '.join(unknown)}") + raise ConfigError(f"{context} contains unknown fields: {', '.join(unknown)}") def _get_table( @@ -83,10 +83,10 @@ def _get_table( value = table.get(key, _MISSING) if value is _MISSING: if required: - raise ConfigError(f"缺少必填配置表 {context}.{key}") + raise ConfigError(f"missing required config table {context}.{key}") return {} if not isinstance(value, dict): - raise ConfigError(f"{context}.{key} 必须是配置表") + raise ConfigError(f"{context}.{key} must be a config table") return value @@ -100,11 +100,11 @@ def _get_string( ) -> str: value = table.get(key, default) if value is _MISSING: - raise ConfigError(f"缺少必填配置 {context}.{key}") + raise ConfigError(f"missing required config {context}.{key}") if not isinstance(value, str): - raise ConfigError(f"{context}.{key} 必须是字符串") + raise ConfigError(f"{context}.{key} must be a string") if not allow_empty and not value: - raise ConfigError(f"{context}.{key} 不能为空") + raise ConfigError(f"{context}.{key} cannot be empty") return value @@ -113,7 +113,7 @@ def _get_int( ) -> int: value = table.get(key, default) if isinstance(value, bool) or not isinstance(value, int): - raise ConfigError(f"{context}.{key} 必须是整数") + raise ConfigError(f"{context}.{key} must be an integer") return value @@ -122,10 +122,10 @@ def _get_number( ) -> float: value = table.get(key, default) if isinstance(value, bool) or not isinstance(value, (int, float)): - raise ConfigError(f"{context}.{key} 必须是数字") + raise ConfigError(f"{context}.{key} must be a number") result = float(value) if not math.isfinite(result): - raise ConfigError(f"{context}.{key} 必须是有限数字") + raise ConfigError(f"{context}.{key} must be a finite number") return result @@ -135,22 +135,22 @@ def _optional_path(table: Mapping[str, Any], key: str, context: str) -> str | No def load_config(path: Path | None = None) -> AppConfig: - """读取并严格校验一个 TOML 配置文件。""" + """Read and strictly validate one TOML config file.""" if path is None: path = Path.cwd() / CONFIG_FILENAME try: with path.open("rb") as file: raw = tomllib.load(file) except FileNotFoundError as exc: - raise ConfigError(f"配置文件不存在:{path}") from exc + raise ConfigError(f"config file not found: {path}") from exc except OSError as exc: - raise ConfigError(f"无法读取配置文件 {path}:{exc}") from exc + raise ConfigError(f"cannot read config file {path}: {exc}") from exc except tomllib.TOMLDecodeError as exc: - raise ConfigError(f"TOML 格式错误:{exc}") from exc + raise ConfigError(f"TOML syntax error: {exc}") from exc - _reject_unknown(raw, {"server", "fetch", "response", "account", "accounts"}, "根配置") + _reject_unknown(raw, {"server", "fetch", "response", "account", "accounts"}, "root config") - server_raw = _get_table(raw, "server", "根配置") + server_raw = _get_table(raw, "server", "root config") _reject_unknown( server_raw, {"host", "port", "api_token", "ssl_certfile", "ssl_keyfile"}, @@ -158,11 +158,11 @@ def load_config(path: Path | None = None) -> AppConfig: ) port = _get_int(server_raw, "port", "server", default=18443) if not 1 <= port <= 65535: - raise ConfigError("server.port 必须在 1 到 65535 之间") + raise ConfigError("server.port must be between 1 and 65535") ssl_certfile = _optional_path(server_raw, "ssl_certfile", "server") ssl_keyfile = _optional_path(server_raw, "ssl_keyfile", "server") if bool(ssl_certfile) != bool(ssl_keyfile): - raise ConfigError("server.ssl_certfile 与 server.ssl_keyfile 必须同时配置或同时留空") + raise ConfigError("server.ssl_certfile and server.ssl_keyfile must be set together or both empty") server = ServerConfig( host=_get_string(server_raw, "host", "server", default="0.0.0.0"), port=port, @@ -171,24 +171,24 @@ def load_config(path: Path | None = None) -> AppConfig: ssl_keyfile=ssl_keyfile, ) - fetch_raw = _get_table(raw, "fetch", "根配置", required=False) + fetch_raw = _get_table(raw, "fetch", "root config", required=False) _reject_unknown(fetch_raw, {"timeout", "retries", "locale", "user_agent"}, "fetch") timeout = _get_number(fetch_raw, "timeout", "fetch", default=10.0) if timeout <= 0: - raise ConfigError("fetch.timeout 必须大于 0") + raise ConfigError("fetch.timeout must be greater than 0") retries = _get_int(fetch_raw, "retries", "fetch", default=1) if retries < 0: - raise ConfigError("fetch.retries 不能为负数") + raise ConfigError("fetch.retries cannot be negative") fetch = FetchConfig( timeout=timeout, retries=retries, - locale=_get_string(fetch_raw, "locale", "fetch", default="zh"), + locale=_get_string(fetch_raw, "locale", "fetch", default="en"), user_agent=_get_string( fetch_raw, "user_agent", "fetch", default=DEFAULT_USER_AGENT ), ) - response_raw = _get_table(raw, "response", "根配置", required=False) + response_raw = _get_table(raw, "response", "root config", required=False) _reject_unknown(response_raw, {"data_template"}, "response") data_template = _get_string( response_raw, @@ -201,25 +201,25 @@ def load_config(path: Path | None = None) -> AppConfig: try: validate_template(data_template) except ValueError as exc: - raise ConfigError(f"response.data_template 模板非法:{exc}") from exc + raise ConfigError(f"response.data_template template is invalid: {exc}") from exc response = ResponseConfig(data_template=data_template) - account_raw = _get_table(raw, "account", "根配置") + account_raw = _get_table(raw, "account", "root config") _reject_unknown(account_raw, {"default"}, "account") default_account = _get_string(account_raw, "default", "account") - accounts_raw = _get_table(raw, "accounts", "根配置") + accounts_raw = _get_table(raw, "accounts", "root config") if not accounts_raw: - raise ConfigError("accounts 至少需要配置一个账号") + raise ConfigError("accounts must define at least one account") accounts: dict[str, AccountConfig] = {} for account_id, value in accounts_raw.items(): context = f"accounts.{account_id}" if not _ACCOUNT_ID_RE.fullmatch(account_id): raise ConfigError( - f"账号 ID {account_id!r} 非法:仅允许 1-64 位字母、数字、_、-,且首位须为字母或数字" + f"invalid account ID {account_id!r}: only 1-64 letters, digits, _, - allowed, and the first character must be a letter or digit" ) if not isinstance(value, dict): - raise ConfigError(f"{context} 必须是配置表") + raise ConfigError(f"{context} must be a config table") _reject_unknown(value, {"auth_cookie", "workspace_id"}, context) accounts[account_id] = AccountConfig( account_id=account_id, @@ -228,7 +228,7 @@ def load_config(path: Path | None = None) -> AppConfig: ) if default_account not in accounts: - raise ConfigError(f"默认账号 {default_account!r} 不存在于 accounts") + raise ConfigError(f"default account {default_account!r} not present in accounts") return AppConfig( server=server, diff --git a/opencode_go_usage_api/fetcher.py b/opencode_go_usage_api/fetcher.py index 118b2ac..75e730f 100644 --- a/opencode_go_usage_api/fetcher.py +++ b/opencode_go_usage_api/fetcher.py @@ -1,4 +1,4 @@ -"""抓取 OpenCode Go 工作区页面。""" +"""Fetch the OpenCode Go workspace page.""" from __future__ import annotations @@ -10,30 +10,33 @@ class FetchError(Exception): - """抓取阶段失败(网络、超时或上游非 200)。""" + """Fetch-phase failure (network, timeout, or non-200 upstream).""" class AuthExpiredError(FetchError): - """被重定向到登录页:auth cookie 失效,或 workspace_id 错误/无权访问。重试无意义。""" + """Redirected to the login page: auth cookie expired, or workspace_id wrong/unreachable. Retrying is pointless.""" _LOGIN_PAGE_MARKER = "OpenAuth" -# 网络异常重试前的固定退避,避免立即重试 +# Fixed backoff before retrying a network error, to avoid immediate retries RETRY_BACKOFF_SECONDS = 0.5 class _FrozenCookies(httpx2.Cookies): - """忽略响应 Set-Cookie 的 cookie jar。 + """Cookie jar that ignores response Set-Cookie headers. - 常驻 Client 的 jar 会被上游响应改写;冻结后每次请求只携带配置里的 - 凭据,与旧的"每次抓取新建 Client"语义一致。 + A persistent Client's jar gets rewritten by upstream responses; freezing it + means each request only carries the credentials from the config, matching the + old "create a new Client per fetch" semantics. - 官方对禁用 cookie 持久化的跟踪 issue(本实现即其中的社区 workaround): + Official tracking issue for disabling cookie persistence (this implementation + is the community workaround for it): https://github.com/pydantic/httpx2/issues/801 - TODO: 若官方落地冻结 Cookie 的方案(如 httpx.NoCookies()), - 用官方 API 替换本类及 create_client 中的 _cookies 私有属性赋值。 + TODO: if an official frozen-cookie option lands (e.g. httpx.NoCookies()), + replace this class and the _cookies private-attribute assignment in + create_client with the official API. """ def extract_cookies(self, response: httpx2.Response) -> None: @@ -41,19 +44,21 @@ def extract_cookies(self, response: httpx2.Response) -> None: def create_client(account: AccountConfig, settings: FetchConfig) -> httpx2.Client: - """为一个账号创建应用生命周期内常驻的 Client,复用连接池/TLS 会话。 + """Create an app-lifetime persistent Client for one account, reusing the connection pool/TLS session. - 每个账号独立 Client,cookie 互不可见,避免共享 jar 串号;且 httpx2 - 跨重定向时会丢弃请求级 Cookie 头、只用 Client jar 重建,凭据必须放 - Client 级才能在站内重定向后仍然有效。 + Each account gets its own Client so cookies never bleed between accounts; + and since httpx2 drops request-level Cookie headers on redirects and rebuilds + them from the Client jar, credentials must live at the Client level to survive + in-site redirects. """ client = httpx2.Client( timeout=settings.timeout, follow_redirects=True, headers={"User-Agent": settings.user_agent, "Accept": "text/html"}, ) - # Client 构造器和 cookies setter 都会把传入值重新包成普通 Cookies, - # 想用冻结 jar 只能在构造后直接替换。 + # Both the Client constructor and the cookies setter re-wrap the value in a + # plain Cookies, so a frozen jar can only be installed by replacing the + # attribute after construction. client._cookies = _FrozenCookies( {"auth": account.auth_cookie, "oc_locale": settings.locale} ) @@ -61,7 +66,7 @@ def create_client(account: AccountConfig, settings: FetchConfig) -> httpx2.Clien def _is_login_page(resp: httpx2.Response) -> bool: - """判断响应是否落在登录或选择登录方式页面。""" + """Return whether the response landed on the login or login-method page.""" final = resp.url if final.host == "auth.opencode.ai": return True @@ -73,7 +78,7 @@ def _is_login_page(resp: httpx2.Response) -> bool: def fetch_html( account: AccountConfig, settings: FetchConfig, client: httpx2.Client ) -> str: - """通过该账号的常驻 Client 实时抓取一次工作区页面。""" + """Live-fetch the workspace page once through the account's persistent Client.""" last_exc: Exception | None = None for attempt in range(settings.retries + 1): if attempt: @@ -82,14 +87,14 @@ def fetch_html( resp = client.get(account.workspace_url) if _is_login_page(resp): raise AuthExpiredError( - "被重定向到登录页,登录凭证可能已失效,或 workspace_id 错误/无权访问" + "redirected to the login page; the auth cookie may have expired, or workspace_id is wrong or unreachable" ) if resp.status_code != 200: - raise FetchError(f"上游返回 HTTP {resp.status_code}") + raise FetchError(f"upstream returned HTTP {resp.status_code}") return resp.text except FetchError: - # 凭证失效和上游非 200 都是明确失败,重试无意义,直接上报 + # Expired credentials and non-200 responses are definitive failures; retrying is pointless raise - except Exception as exc: # noqa: BLE001 网络层异常(超时、连接失败)统一重试 + except Exception as exc: # noqa: BLE001 network-layer errors (timeout, connect failure) all retry last_exc = exc - raise FetchError(f"无法连接 OpenCode(超时或上游异常):{last_exc}") + raise FetchError(f"cannot connect to OpenCode (timeout or upstream error): {last_exc}") diff --git a/opencode_go_usage_api/formatter.py b/opencode_go_usage_api/formatter.py index 2569115..3bf2ddc 100644 --- a/opencode_go_usage_api/formatter.py +++ b/opencode_go_usage_api/formatter.py @@ -1,13 +1,13 @@ -"""用量响应格式化。""" +"""Usage response formatting.""" from __future__ import annotations from .models import Usage DEFAULT_DATA_TEMPLATE = ( - "滚动 {rolling_percent}% ({rolling_reset}) | " - "周 {weekly_percent}% ({weekly_reset}) | " - "月 {monthly_percent}% ({monthly_reset})" + "Rolling {rolling_percent}% ({rolling_reset}) | " + "Weekly {weekly_percent}% ({weekly_reset}) | " + "Monthly {monthly_percent}% ({monthly_reset})" ) _SECTIONS = ("rolling", "weekly", "monthly") @@ -16,7 +16,7 @@ def fmt_reset(u: Usage) -> str: - """倒计时展示:优先格式化秒数,否则使用 DOM 兜底文本。""" + """Countdown display: prefer formatting the seconds, otherwise fall back to the DOM text.""" sec = u.reset_in_sec if sec is None: return u.reset_text or "?" @@ -33,7 +33,7 @@ def fmt_reset(u: Usage) -> str: class _SafeDict(dict): - """让未知占位符保留原样,便于发现模板拼写错误。""" + """Leave unknown placeholders intact so template typos are easy to spot.""" def __missing__(self, key: str) -> str: return "{" + key + "}" @@ -54,7 +54,7 @@ def _build_values(usages: dict[str, Usage]) -> _SafeDict: def validate_template(template: str) -> None: - """验证模板语法;未知的简单占位符仍被允许并保留。""" + """Validate template syntax; unknown simple placeholders are still allowed and preserved.""" probe = _SafeDict({f"{s}_{f}": "0" for s in _SECTIONS for f in _FIELDS}) try: template.format_map(probe) @@ -65,7 +65,7 @@ def validate_template(template: str) -> None: def build_data( usages: dict[str, Usage], data_template: str = DEFAULT_DATA_TEMPLATE ) -> str: - """按指定模板渲染 data 字段。""" + """Render the data field using the given template.""" values = _build_values(usages) try: return data_template.format_map(values) diff --git a/opencode_go_usage_api/models.py b/opencode_go_usage_api/models.py index 4bf85a7..9609328 100644 --- a/opencode_go_usage_api/models.py +++ b/opencode_go_usage_api/models.py @@ -1,4 +1,4 @@ -"""数据结构。""" +"""Data structures.""" from __future__ import annotations @@ -7,10 +7,11 @@ @dataclass(frozen=True) class Usage: - """单项用量:已用百分比 + 距下次重置的秒数 + 状态。 + """A single usage entry: percent used + seconds until next reset + status. - 内联 JSON 路径填 reset_in_sec(精确秒数);DOM 兜底路径拿不到秒数, - 填 reset_text(页面上「重置于 X 天 Y 小时」原文)由 fmt_reset 直接展示。 + The inline JSON path fills reset_in_sec (exact seconds); the DOM fallback path + can't get the seconds, so it fills reset_text (the raw "resets in X days Y hours" + text from the page) which fmt_reset displays directly. """ percent: int diff --git a/opencode_go_usage_api/parser.py b/opencode_go_usage_api/parser.py index 1cf442e..18fc9a5 100644 --- a/opencode_go_usage_api/parser.py +++ b/opencode_go_usage_api/parser.py @@ -1,9 +1,10 @@ -"""解析:内联 JSON 主用,DOM 兜底。 +"""Parsing: inline JSON as the primary path, DOM as the fallback. -内联数据形如: +Inline data looks like: rollingUsage: $R[31] = { status: "ok", resetInSec: 18000, usagePercent: 0 } -三个键名固定,值块非严格 JSON(含 !0、new Date() 等),因此对每个用量块 -单独用正则抽取三个数值字段,互不依赖字段出现顺序。 +The three keys have fixed names, but the value blocks are not strict JSON (they +contain !0, new Date(), etc.), so each usage block is matched separately with a +regex that extracts the three numeric fields regardless of field order. """ from __future__ import annotations @@ -18,21 +19,22 @@ "monthly": "monthlyUsage", } -# 用量块的值紧跟在 `:` 之后,形如 `:$R[31]={...}` 或 `:{...}`。 -# 真实页面里 `monthlyUsage` 会出现两次:真正的用量块 `:$R[n]={...}`, -# 以及订阅信息块里的 `:null`(值是 null,非用量数据)。只匹配前者, -# 跳过 `:null`,否则约一半请求会错抓到 null 后面无关的空 {} 块、丢掉 resetInSec。 -# `:` 与 `{` 之间允许出现 `$R[数字]=` 这种 hydration 赋值前缀,或仅空白。 +# A usage block's value follows `:` directly, like `:$R[31]={...}` or `:{...}`. +# On the real page `monthlyUsage` appears twice: the actual usage block `:$R[n]={...}`, +# and a `:null` entry inside the subscription-info block (value is null, not usage data). +# Only match the former and skip `:null`; otherwise roughly half of requests would latch +# onto an unrelated empty {} block after the null and lose resetInSec. +# Between `:` and `{` there may be a `$R[=` hydration prefix, or just whitespace. _USAGE_VALUE_RE = r"\s*:\s*(?:\$R\[\d+\]\s*=\s*)?\{" def _extract_usage_block(html: str, key: str) -> str | None: - """从内联脚本里截出某个用量键对应的 {...} 块文本。""" - # 定位 `:` 之后紧跟 `{`(可带 $R[n]= 前缀)的位置,再做花括号配平找块结尾。 + """Slice out the {...} block text for a given usage key from the inline script.""" + # Locate `:` followed by `{` (optionally after a $R[n]= prefix), then brace-balance to the end. m = re.search(re.escape(key) + _USAGE_VALUE_RE, html) if not m: return None - brace_start = m.end() - 1 # 落在开括号 { + brace_start = m.end() - 1 # lands on the opening brace { depth = 0 for i in range(brace_start, len(html)): c = html[i] @@ -56,7 +58,7 @@ def _parse_str_field(block: str, field: str) -> str | None: def parse_inline(html: str) -> dict[str, Usage]: - """从内联 JSON 区解析三组用量;缺失的项不放入结果。""" + """Parse the three usage groups from the inline JSON region; missing items are omitted.""" result: dict[str, Usage] = {} for name, key in _USAGE_KEYS.items(): block = _extract_usage_block(html, key) @@ -73,12 +75,14 @@ def parse_inline(html: str) -> dict[str, Usage]: return result -# 当内联区结构变化抽不到时,退回渲染后 DOM 抓百分比。 -# DOM 里没有精确 resetInSec,但 reset-time span 有「重置于 X 天 Y 小时」原文, -# 直接抠出来原文返回(不反解析为秒、不依赖语言),仅百分比仍由 DOM 提供。 +# When the inline region's structure changes and nothing can be extracted, fall back to +# grabbing percentages from the rendered DOM. The DOM has no precise resetInSec, but the +# reset-time span carries the raw "resets in X days Y hours" text, which is returned +# verbatim (not re-parsed into seconds, not language-dependent); only the percentage +# still comes from the DOM. # -# 页面固定包含 3 个 data-slot="usage-item" 块,顺序为 rolling → weekly → monthly, -# 通过结构定位而非文本标签匹配,因此与 locale 无关。 +# The page always contains 3 data-slot="usage-item" blocks in rolling -> weekly -> monthly +# order, located by structure rather than text labels, so it is locale-independent. _DOM_ORDER = ("rolling", "weekly", "monthly") @@ -86,7 +90,7 @@ def parse_inline(html: str) -> dict[str, Usage]: def _clean_reset_text(raw: str) -> str | None: - """把 reset-time span 内文去掉 SSR 注释、压空白,得到展示用文本。""" + """Strip SSR comments and collapse whitespace from the reset-time span for display.""" cleaned = re.sub(r"", "", raw, flags=re.S) cleaned = re.sub(r"\s+", " ", cleaned).strip() return cleaned or None @@ -98,14 +102,14 @@ def parse_dom(html: str) -> dict[str, Usage]: for idx, name in enumerate(_DOM_ORDER): if idx >= len(item_starts): break - # 截取当前 item 到下一个 item(或 +800 字符兜底)之间的片段 + # Slice from the current item to the next one (or +800 chars as a fallback) seg_start = item_starts[idx] seg_end = item_starts[idx + 1] if idx + 1 < len(item_starts) else seg_start + 800 segment = html[seg_start:seg_end] - # 优先从 usage-value slot 抽百分比 + # Prefer the percentage from the usage-value slot m = re.search(r'data-slot="usage-value">\s*(?:)?\s*(\d+)', segment, re.S) if not m: - # 退一步:从 progress-bar 的 width:N% 抽 + # Fall back to the progress-bar width:N% m = re.search(r"width:\s*(\d+)%", segment) if not m: continue @@ -121,31 +125,32 @@ def parse_dom(html: str) -> dict[str, Usage]: def parse_usage(html: str) -> dict[str, Usage]: - """先内联后 DOM;对内联缺失的单项用 DOM 补齐。""" + """Inline first, DOM second; fill any item missing from inline with the DOM value.""" inline = parse_inline(html) if len(inline) == 3: return inline dom = parse_dom(html) - # 合并:内联优先,缺的项用 DOM 补 + # Merge: inline wins, missing items are backfilled from the DOM merged = dict(dom) merged.update(inline) return merged def is_no_subscription(html: str) -> bool: - """识别「无 Go 订阅」页面:用量块整体缺失时的稳定兜底判定。 + """Detect the "no Go subscription" page, the stable fallback when usage blocks are absent. - 无订阅页既没有内联用量对象,也没有 - DOM 用量节点,parse_usage 会返回空。此时需与真正的 cookie 失效/结构变更 - 区分:无订阅页有明显的促销订阅区,且订阅状态字段为 null。 + The no-subscription page has neither inline usage objects nor DOM usage nodes, so + parse_usage returns empty. At that point it must be distinguished from a genuinely + expired cookie or a changed page structure: the no-subscription page has a prominent + promo subscription section and its subscription status field is null. - 主判据:DOM 存在「订阅 Go」按钮 data-slot="subscribe-button"(有订阅页 - 对应的是「管理订阅」按钮,无此 slot)。辅以内联 lite: null 印证,避免 - 某个无关促销横幅误触发。 + Primary signal: a "Subscribe to Go" button with data-slot="subscribe-button" exists + (the subscribed page has a "Manage subscription" button without this slot). Backed up + by an inline `lite: null`, to avoid an unrelated promo banner misfiring. """ if 'data-slot="subscribe-button"' not in html: return False - # lite: null 出现在 billing 块;有订阅页此处是 lite: {...} 或带 liteSubscriptionID + # lite: null appears in the billing block; the subscribed page has lite: {...} or a liteSubscriptionID return bool(re.search(r"lite\s*:\s*null", html)) and bool( re.search(r"liteSubscriptionID\s*:\s*null", html) ) diff --git a/opencode_go_usage_api/service.py b/opencode_go_usage_api/service.py index 1187f70..b317d02 100644 --- a/opencode_go_usage_api/service.py +++ b/opencode_go_usage_api/service.py @@ -1,4 +1,4 @@ -"""抓取、解析并组装单个账号的响应。""" +"""Fetch, parse, and assemble the response for a single account.""" from __future__ import annotations @@ -16,19 +16,19 @@ def build_response( data_template: str, client: httpx2.Client, ) -> dict: - """实时查询一个账号;任何业务失败都归一为 success:false。""" + """Live-query one account; any business failure is normalized to success:false.""" try: html = fetch_html(account, fetch_config, client) except AuthExpiredError: return { "success": False, - "reason": "登录凭证已失效,请重新获取 auth cookie;若 cookie 确认有效,请检查 workspace_id 是否正确", + "reason": "auth cookie has expired, please re-fetch it; if the cookie is valid, check that workspace_id is correct", "data": "", } except FetchError as exc: return { "success": False, - "reason": f"抓取失败:{exc}", + "reason": f"fetch failed: {exc}", "data": "", } @@ -37,12 +37,12 @@ def build_response( if is_no_subscription(html): return { "success": False, - "reason": "当前账号无 OpenCode Go 订阅", + "reason": "this account has no OpenCode Go subscription", "data": "", } return { "success": False, - "reason": "未能从页面解析出用量数据(页面结构可能已变更)", + "reason": "failed to parse usage data from the page (the page structure may have changed)", "data": "", } diff --git a/pyproject.toml b/pyproject.toml index 760ab2b..aed47fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "opencode-go-usage-api" version = "0.1.0" -description = "实时抓取 OpenCode Go 工作区页面并返回用量 JSON 的 HTTPS API" +description = "HTTPS API that live-scrapes the OpenCode Go workspace page and returns usage as JSON" readme = "README.md" requires-python = ">=3.11" license = "MIT" diff --git a/tests/test_app.py b/tests/test_app.py index 43d031a..f0df86b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -22,7 +22,7 @@ def make_config() -> AppConfig: } return AppConfig( server=ServerConfig("127.0.0.1", 18443, "api-secret", None, None), - fetch=FetchConfig(10, 1, "zh", "test-agent"), + fetch=FetchConfig(10, 1, "en", "test-agent"), response=ResponseConfig("{rolling_percent}"), default_account="Main", accounts=MappingProxyType(accounts), @@ -54,7 +54,7 @@ def fake_builder(account, fetch_config, data_template, http_client): def test_lifespan_creates_per_account_clients_and_closes_them_on_shutdown() -> None: - """每个账号一个常驻 Client 挂在 app.state,路由拿到对应实例,关停时全部关闭。""" + """One persistent Client per account is mounted on app.state, routes get their instance, all close on shutdown.""" seen_clients: list[httpx2.Client] = [] def fake_builder(account, fetch_config, data_template, http_client): @@ -78,7 +78,7 @@ def test_account_ids_are_case_sensitive() -> None: response = client.get("/usage/main", headers=AUTH_HEADER) assert response.status_code == 404 - assert response.json() == {"success": False, "reason": "账号不存在", "data": ""} + assert response.json() == {"success": False, "reason": "account not found", "data": ""} def test_unknown_account_requires_auth_before_404() -> None: @@ -86,24 +86,24 @@ def test_unknown_account_requires_auth_before_404() -> None: response = client.get("/usage/missing") assert response.status_code == 401 - assert response.json() == {"success": False, "reason": "未授权", "data": ""} + assert response.json() == {"success": False, "reason": "unauthorized", "data": ""} def test_non_ascii_authorization_returns_401_not_500() -> None: - """请求头按 latin-1 解码可能含非 ASCII 字符,不应触发 TypeError 变成 500。""" + """A header decoded as latin-1 may contain non-ASCII bytes and must not trigger a TypeError turning into a 500.""" with TestClient(create_app(make_config())) as client: - # 以 bytes 形式直接构造含非 ASCII 字节的头,绕过客户端层的 ASCII 编码检查 + # Build a header with non-ASCII bytes directly as bytes, bypassing the client-side ASCII encoding check response = client.get( "/usage", headers={b"Authorization": "Bearer café".encode("latin-1")} ) assert response.status_code == 401 - assert response.json() == {"success": False, "reason": "未授权", "data": ""} + assert response.json() == {"success": False, "reason": "unauthorized", "data": ""} def test_business_failure_keeps_http_200() -> None: def failed_builder(account, fetch_config, data_template, http_client): - return {"success": False, "reason": "抓取失败:timeout", "data": ""} + return {"success": False, "reason": "fetch failed: timeout", "data": ""} with TestClient(create_app(make_config(), failed_builder)) as client: response = client.get("/usage/backup", headers=AUTH_HEADER) diff --git a/tests/test_config.py b/tests/test_config.py index 9e39756..b7e9f87 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,14 +49,14 @@ def test_rejects_unknown_fields(tmp_path: Path) -> None: 'api_token = "api-secret"\napi_tokne = "typo"', ) - with pytest.raises(ConfigError, match="server 包含未知字段:api_tokne"): + with pytest.raises(ConfigError, match="server contains unknown fields: api_tokne"): load_config(write_config(tmp_path, content)) def test_rejects_missing_default_account(tmp_path: Path) -> None: content = VALID_CONFIG.replace('default = "Main"', 'default = "missing"') - with pytest.raises(ConfigError, match="默认账号 'missing' 不存在"): + with pytest.raises(ConfigError, match="default account 'missing' not present in accounts"): load_config(write_config(tmp_path, content)) @@ -66,7 +66,7 @@ def test_rejects_non_url_safe_account_ids( ) -> None: content = VALID_CONFIG.replace("[accounts.Main]", f'[accounts."{account_id}"]') - with pytest.raises(ConfigError, match="账号 ID .* 非法"): + with pytest.raises(ConfigError, match="invalid account ID .*"): load_config(write_config(tmp_path, content)) @@ -76,14 +76,14 @@ def test_requires_both_tls_paths(tmp_path: Path) -> None: 'api_token = "api-secret"\nssl_certfile = "certs/cert.pem"', ) - with pytest.raises(ConfigError, match="必须同时配置或同时留空"): + with pytest.raises(ConfigError, match="must be set together or both empty"): load_config(write_config(tmp_path, content)) def test_rejects_invalid_data_template(tmp_path: Path) -> None: content = VALID_CONFIG + '\n[response]\ndata_template = "broken {"\n' - with pytest.raises(ConfigError, match="response.data_template 模板非法"): + with pytest.raises(ConfigError, match="response.data_template template is invalid"): load_config(write_config(tmp_path, content)) diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index 7f31fcb..bfbbcf7 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -11,11 +11,11 @@ fetch_html, ) -SETTINGS = FetchConfig(10, 1, "zh", "test-agent") +SETTINGS = FetchConfig(10, 1, "en", "test-agent") def _make_client(account: AccountConfig, handler, settings=SETTINGS) -> httpx2.Client: - """构造走真实常驻 Client 路径的测试客户端,仅拦截网络层。""" + """Build a test client that uses the real persistent-Client path, only intercepting the network layer.""" client = create_client(account, settings) client._transport = httpx2.MockTransport(handler) return client @@ -26,7 +26,7 @@ def _parse_cookie_header(value: str) -> dict[str, str]: def test_each_account_client_sends_own_url_and_cookie() -> None: - """每个账号独立 Client,各自携带自己的凭据,互不可见。""" + """Each account has its own Client that carries its own credentials, invisible to the others.""" requests: list[tuple[str, dict[str, str]]] = [] def handler(request): @@ -43,17 +43,17 @@ def handler(request): assert requests == [ ( "https://opencode.ai/workspace/wrk_main/go", - {"auth": "main-cookie", "oc_locale": "zh"}, + {"auth": "main-cookie", "oc_locale": "en"}, ), ( "https://opencode.ai/workspace/wrk_backup/go", - {"auth": "backup-cookie", "oc_locale": "zh"}, + {"auth": "backup-cookie", "oc_locale": "en"}, ), ] def test_upstream_set_cookie_does_not_pollute_client_jar() -> None: - """常驻 Client 复用时,上游 Set-Cookie 不得改写配置里的凭据。""" + """When the persistent Client is reused, upstream Set-Cookie must not rewrite the configured credentials.""" def handler(request): return httpx2.Response( @@ -68,18 +68,18 @@ def handler(request): fetch_html(account, SETTINGS, client) fetch_html(account, SETTINGS, client) - assert dict(client.cookies) == {"auth": "cookie", "oc_locale": "zh"} + assert dict(client.cookies) == {"auth": "cookie", "oc_locale": "en"} def test_non_200_fails_immediately_without_retry() -> None: - """上游非 200(如 403/404/429)是明确失败,不应重试。""" + """A non-200 upstream response (e.g. 403/404/429) is a definitive failure and should not be retried.""" attempts: list[str] = [] def handler(request): attempts.append(str(request.url)) return httpx2.Response(429, text="rate limited") - settings = FetchConfig(10, 3, "zh", "test-agent") + settings = FetchConfig(10, 3, "en", "test-agent") account = AccountConfig("main", "cookie", "wrk_main") client = _make_client(account, handler, settings) @@ -89,7 +89,7 @@ def handler(request): def test_network_error_retries_with_backoff_then_succeeds(monkeypatch) -> None: - """网络层异常(超时、连接失败)按 retries 重试,重试前有小幅退避。""" + """Network-layer errors (timeout, connect failure) retry per the retries setting, with a small backoff first.""" sleeps: list[float] = [] monkeypatch.setattr("opencode_go_usage_api.fetcher.time.sleep", sleeps.append) attempts: list[str] = [] diff --git a/tests/test_formatter.py b/tests/test_formatter.py index b46dae7..5e6ff4b 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -1,4 +1,4 @@ -"""formatter 模块单元测试。""" +"""Unit tests for the formatter module.""" from __future__ import annotations @@ -40,7 +40,7 @@ def test_negative_clamped_to_zero(self) -> None: assert fmt_reset(_usage(0, reset_in_sec=-100)) == "0m" def test_none_uses_reset_text(self) -> None: - assert fmt_reset(_usage(0, reset_text="重置于 2 天 7 小时")) == "重置于 2 天 7 小时" + assert fmt_reset(_usage(0, reset_text="resets in 2 days 7 hours")) == "resets in 2 days 7 hours" def test_none_no_text_returns_question(self) -> None: assert fmt_reset(_usage(0)) == "?" @@ -77,7 +77,7 @@ def test_missing_section_shows_dash(self) -> None: usages = {"rolling": _usage(5, reset_in_sec=100)} result = build_data(usages) assert "5%" in result - assert "—" in result # weekly/monthly 缺失显示 — + assert "—" in result # weekly/monthly missing shows — def test_unknown_placeholder_preserved(self) -> None: usages = {"rolling": _usage(1, reset_in_sec=60)} @@ -91,9 +91,9 @@ def test_broken_template_falls_back_to_default(self) -> None: "weekly": _usage(20, reset_in_sec=120), "monthly": _usage(30, reset_in_sec=180), } - # 带属性访问的模板会触发 AttributeError + # A template with attribute access triggers AttributeError result = build_data(usages, "{rolling_percent.attr}") - # 回退到默认模板,仍能渲染 + # Falls back to the default template, still renders assert "10%" in result diff --git a/tests/test_parser.py b/tests/test_parser.py index cf9cb42..5f6ab47 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,4 +1,4 @@ -"""parser 模块单元测试。""" +"""Unit tests for the parser module.""" from __future__ import annotations @@ -9,8 +9,8 @@ parse_usage, ) -# ---------- 内联 JSON fixtures ---------- -# 基于真实抓包页面:lite.subscription.get 资源块 +# ---------- inline JSON fixtures ---------- +# Based on the real captured page: the lite.subscription.get resource block INLINE_HTML = """ """ -# monthlyUsage 出现两次:billing 块里 null(订阅信息),subscription 块里真正的用量 +# monthlyUsage appears twice: null in the billing block (subscription info), and the real usage in the subscription block INLINE_DUPLICATE_MONTHLY_HTML = """ """ -# 有订阅页面 —— 基于真实抓包(订阅状态区 + billing 脚本块) +# Subscribed page - based on a real capture (subscription status section + billing script block) HAS_SUBSCRIPTION_HTML = """
-

您已订阅 OpenCode Go。

- +

You are subscribed to OpenCode Go.

+
@@ -327,19 +327,19 @@ def test_empty_html(self) -> None: class TestParseDom: - def test_parses_chinese_locale(self) -> None: - result = parse_dom(DOM_HTML_ZH) + def test_parses_usage_items(self) -> None: + result = parse_dom(DOM_HTML) assert len(result) == 3 assert result["rolling"].percent == 0 assert result["rolling"].reset_in_sec is None - assert "5 小时 0 分钟" in (result["rolling"].reset_text or "") + assert "5 hours 0 minutes" in (result["rolling"].reset_text or "") assert result["weekly"].percent == 5 - assert "2 天 4 小时" in (result["weekly"].reset_text or "") + assert "2 days 4 hours" in (result["weekly"].reset_text or "") assert result["monthly"].percent == 2 - assert "29 天 11 小时" in (result["monthly"].reset_text or "") + assert "29 days 11 hours" in (result["monthly"].reset_text or "") - def test_parses_english_locale_by_structure(self) -> None: - """DOM 解析不依赖标签文本,英文 locale 同样工作。""" + def test_parses_by_structure_ignoring_locale(self) -> None: + """DOM parsing relies on the data-slot structure, not the label text, so it works regardless of locale.""" result = parse_dom(DOM_HTML_EN) assert len(result) == 3 assert result["rolling"].percent == 12 @@ -367,20 +367,20 @@ class TestParseUsage: def test_inline_complete_skips_dom(self) -> None: result = parse_usage(INLINE_HTML) assert len(result) == 3 - # 内联路径有 reset_in_sec + # The inline path has reset_in_sec assert result["rolling"].reset_in_sec == 18000 assert result["weekly"].reset_in_sec == 189112 assert result["monthly"].reset_in_sec == 2547316 def test_merges_dom_for_missing_inline(self) -> None: - """内联只有 2 项时,DOM 补齐缺失项。""" - html = INLINE_PARTIAL_HTML + DOM_HTML_ZH + """When inline has only 2 items, the DOM backfills the missing one.""" + html = INLINE_PARTIAL_HTML + DOM_HTML result = parse_usage(html) assert len(result) == 3 - # rolling/weekly 来自内联 + # rolling/weekly come from inline assert result["rolling"].reset_in_sec == 14520 assert result["weekly"].reset_in_sec == 345600 - # monthly 来自 DOM 兜底 + # monthly comes from the DOM fallback assert result["monthly"].percent == 2 assert result["monthly"].reset_in_sec is None diff --git a/tests/test_service.py b/tests/test_service.py index 6140c86..e32d1ca 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,4 +1,4 @@ -"""service 模块单元测试。""" +"""Unit tests for the service module.""" from __future__ import annotations @@ -7,9 +7,9 @@ from opencode_go_usage_api.service import build_response ACCOUNT = AccountConfig("test", "cookie", "wrk_test") -FETCH_CFG = FetchConfig(timeout=10, retries=1, locale="zh", user_agent="test") +FETCH_CFG = FetchConfig(timeout=10, retries=1, locale="en", user_agent="test") TEMPLATE = "{rolling_percent}% | {weekly_percent}% | {monthly_percent}%" -# build_response 只把它透传给被 mock 掉的 fetch_html,占位对象即可 +# build_response only passes it through to the mocked fetch_html; a placeholder object is enough CLIENT = object() INLINE_OK_HTML = """ @@ -40,10 +40,10 @@ NO_SUB_HTML = """

- OpenCode Go 起价为 首月 $5,之后 $10/月。 + OpenCode Go starts at $5 for the first month, then $10/month.

- +