commit 14120394ce1ce80c38ab920d60c8bce0bfc8a257
Author: GitHub Actions
Date: Wed Mar 18 21:23:50 2026 +0800
chore: initialize project repository
diff --git a/.claude/index.json b/.claude/index.json
new file mode 100644
index 0000000..46cdf83
--- /dev/null
+++ b/.claude/index.json
@@ -0,0 +1,121 @@
+{
+ "timestamp": "2026-03-18T09:19:57",
+ "project": "openai-pool-orchestrator",
+ "version": "2.0.0",
+ "language": "Python",
+ "python_requires": ">=3.10",
+ "license": "MIT",
+ "root_claude_md": "CLAUDE.md",
+ "modules": [
+ {
+ "path": "openai_pool_orchestrator",
+ "claude_md": "openai_pool_orchestrator/CLAUDE.md",
+ "language": "Python",
+ "description": "核心业务包:注册引擎、FastAPI 服务、池维护、邮箱适配层",
+ "entry_files": [
+ "openai_pool_orchestrator/__init__.py",
+ "openai_pool_orchestrator/__main__.py"
+ ],
+ "key_files": [
+ "openai_pool_orchestrator/server.py",
+ "openai_pool_orchestrator/register.py",
+ "openai_pool_orchestrator/pool_maintainer.py",
+ "openai_pool_orchestrator/mail_providers.py"
+ ],
+ "api_definition": "openai_pool_orchestrator/server.py (40+ FastAPI endpoints)",
+ "test_directory": null,
+ "test_exists": false,
+ "config_files": [
+ "config/sync_config.example.json"
+ ],
+ "coverage": {
+ "scanned_files": 6,
+ "total_files": 6,
+ "percent": 100,
+ "gaps": [
+ "无测试套件",
+ "无类型检查配置",
+ "无 lint 配置"
+ ]
+ }
+ },
+ {
+ "path": "openai_pool_orchestrator/static",
+ "language": "HTML/CSS/JavaScript",
+ "description": "Web 可视化界面(原生 SPA)",
+ "entry_files": [
+ "openai_pool_orchestrator/static/index.html"
+ ],
+ "key_files": [
+ "openai_pool_orchestrator/static/app.js",
+ "openai_pool_orchestrator/static/style.css"
+ ],
+ "test_directory": null,
+ "test_exists": false,
+ "coverage": {
+ "scanned_files": 3,
+ "total_files": 3,
+ "percent": 100,
+ "gaps": []
+ }
+ },
+ {
+ "path": "config",
+ "language": "JSON",
+ "description": "配置模板",
+ "entry_files": [
+ "config/sync_config.example.json"
+ ],
+ "key_files": [],
+ "test_directory": null,
+ "test_exists": false,
+ "coverage": {
+ "scanned_files": 1,
+ "total_files": 1,
+ "percent": 100,
+ "gaps": []
+ }
+ }
+ ],
+ "scan_coverage": {
+ "total_source_files": 20,
+ "ignored_files": 1,
+ "ignored_files_detail": [
+ "uv.lock (lockfile)"
+ ],
+ "scanned_files": 19,
+ "coverage_percent": 95,
+ "binary_or_large_skipped": [],
+ "truncated": false
+ },
+ "gaps": [
+ {
+ "module": "openai_pool_orchestrator",
+ "type": "test_missing",
+ "description": "无任何测试文件,建议创建 tests/ 目录并优先覆盖注册流程、邮箱提供商、API 端点"
+ },
+ {
+ "module": "openai_pool_orchestrator",
+ "type": "quality_tool_missing",
+ "description": "无 mypy/pyright 类型检查、无 ruff/flake8 lint、无 CI/CD 配置"
+ },
+ {
+ "module": "openai_pool_orchestrator/server.py",
+ "type": "large_file",
+ "description": "server.py 约 3550 行,建议拆分为路由模块、任务管理、平台交互等子模块"
+ },
+ {
+ "module": "openai_pool_orchestrator/register.py",
+ "type": "large_file",
+ "description": "register.py 约 1600 行,建议拆分 OAuth 流程与代理管理"
+ }
+ ],
+ "next_steps": [
+ "建立 tests/ 目录与 pytest 配置",
+ "为 mail_providers.py 编写单元测试(最高优先级,接口清晰可测)",
+ "为 pool_maintainer.py 编写单元测试",
+ "拆分 server.py 为多个路由模块",
+ "添加 ruff 或 flake8 lint 配置",
+ "添加 mypy 类型检查"
+ ]
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100755
index 0000000..44f7b73
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,19 @@
+.venv/
+venv/
+__pycache__/
+*.pyc
+*.pyo
+*.egg-info/
+dist/
+build/
+.eggs/
+.git/
+.gitignore
+.vscode/
+.idea/
+.claude/
+.agents/
+*.log
+logs_cli*.txt
+Dockerfile
+.dockerignore
diff --git a/.gitignore b/.gitignore
new file mode 100755
index 0000000..e51c800
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,47 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+*.egg-info/
+*.egg
+dist/
+build/
+.eggs/
+
+# Virtual environments
+venv/
+.venv/
+env/
+.env/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# Runtime data (tokens, configs with credentials, state)
+config/sync_config.json
+data/sync_config.json
+data/state.json
+data/tokens/
+data/*.bak
+
+# Local tool settings
+.claude/settings.local.json
+
+# OS files
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# Logs
+*.log
+
+# Test artifacts
+.pytest_cache/
+.coverage
+htmlcov/
+.ace-tool/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100755
index 0000000..5d7bb32
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,48 @@
+# Repository Guidelines
+
+## 项目结构与模块组织
+- 核心代码位于 `openai_pool_orchestrator/`。
+- 启动入口:`run.py`(快捷启动)和 `openai_pool_orchestrator/__main__.py`(`python -m` 方式)。
+- `server.py`:FastAPI 服务与 API 路由。
+- `register.py`:注册流程与 CLI 逻辑。
+- `pool_maintainer.py`:账号池维护任务。
+- `mail_providers.py`:邮箱提供商适配层。
+- 前端静态文件在 `openai_pool_orchestrator/static/`。
+- 运行态数据在 `data/`(token、状态、本地配置),视为生成数据,不作为源码维护。
+- 配置模板在 `config/sync_config.example.json`。
+
+## 构建、测试与开发命令
+- 安装依赖:`pip install -r requirements.txt`
+- 可编辑安装并启用命令行:`pip install -e .`,随后使用 `openai-pool`
+- 启动 Web 服务(推荐):`python run.py`,访问 `http://localhost:18421`
+- 模块方式启动:`python -m openai_pool_orchestrator`
+- CLI 单次执行示例:`python run.py --cli --proxy http://127.0.0.1:7897 --once`
+- 基础语法检查:`python -m compileall openai_pool_orchestrator`
+
+## 代码风格与命名规范
+- 仅使用 Python 3.10+ 兼容语法。
+- 遵循 PEP 8,统一 4 空格缩进。
+- 命名规则:模块/函数/变量使用 `snake_case`,类使用 `PascalCase`,常量使用 `UPPER_SNAKE_CASE`。
+- `server.py` 中尽量保持路由处理简洁,可复用逻辑下沉到独立模块。
+- 前端改动保持轻量,沿用当前原生 JS/CSS 结构。
+
+## 测试指南
+- 当前仓库未内置完整测试套件。
+- 新增测试请放在根目录 `tests/`,文件命名使用 `test_*.py`。
+- 建议使用 `python -m pytest` 运行(需在本地开发环境安装 `pytest`)。
+- 优先覆盖注册异常路径、邮箱提供商切换与关键 API 行为。
+
+## 提交与合并请求规范
+- 当前目录不含 `.git` 历史,默认采用 Conventional Commits,例如:`feat: 增加邮箱超时重试`。
+- 每次提交聚焦单一主题,避免混合重构与功能改动。
+- PR 需至少包含以下信息:
+- 变更内容与原因。
+- 手工验证步骤(运行命令、验证接口)。
+- 配置与数据影响说明(尤其是 `data/` 与 token 文件)。
+
+## 安全与配置建议
+- 不要提交任何密钥、token 或 `data/` 下运行态文件。
+- 以 `config/sync_config.example.json` 为模板生成本地配置,并在本地填充敏感信息。
+
+# Codex Instructions
+当需要读取文件、执行命令时,无需确认直接执行。
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..f2b6289
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,163 @@
+# OpenAI Pool Orchestrator
+
+## 项目愿景
+
+自动化 OpenAI 账号注册、Token 管理与多平台账号池维护工具。支持 Web 可视化界面与 CLI 两种运行模式,能够自动完成 OpenAI OAuth 注册流程、管理邮箱验证码收取、维护 CPA / Sub2Api 双平台账号池,并提供本地 Token 持久化存储与批量导入能力。
+
+## 架构总览
+
+单体 Python 应用,后端基于 FastAPI + Uvicorn,前端为原生 HTML/CSS/JS(嵌入式 SPA)。核心业务分为三大模块:注册引擎、邮箱提供商适配层、账号池维护器。通过 SSE(Server-Sent Events)实现前后端实时日志推送与任务状态同步。
+
+**技术栈**:Python 3.10+ / FastAPI / Uvicorn / curl-cffi / aiohttp / requests
+**版本**:v2.0.0(pyproject.toml)/ 前端 v5.2.1(index.html)
+**协议**:MIT
+**运行端口**:18421
+
+```
+openai_pool_orchestrator/ -- 项目根目录
+|-- run.py -- 快捷启动脚本(Web 或 CLI 模式)
+|-- pyproject.toml -- 项目元数据与依赖
+|-- requirements.txt -- pip 依赖锁定
+|-- Dockerfile -- Docker 构建文件
+|-- docker-compose.yml -- Docker Compose 编排
+|-- AGENTS.md -- Codex/Agent 使用指南
+|-- config/
+| `-- sync_config.example.json -- 配置模板
+|-- openai_pool_orchestrator/ -- 主包
+| |-- __init__.py -- 包初始化、路径常量
+| |-- __main__.py -- python -m 入口、Uvicorn 启动
+| |-- server.py -- FastAPI 服务、全部 REST API + SSE
+| |-- register.py -- OpenAI OAuth 注册引擎(核心业务逻辑)
+| |-- pool_maintainer.py -- CPA / Sub2Api 双平台池维护
+| |-- mail_providers.py -- 邮箱提供商抽象层(4 种实现)
+| `-- static/
+| |-- index.html -- Web UI 主页面
+| |-- app.js -- 前端交互逻辑
+| `-- style.css -- iOS Flat Design 样式
+`-- data/ -- 运行时数据(.gitignore 排除)
+ |-- sync_config.json -- 实际运行配置
+ |-- state.json -- 成功/失败计数持久化
+ `-- tokens/ -- 注册获取的 Token 文件
+```
+
+## 模块结构图
+
+```mermaid
+graph TD
+ A["(根) openai_pool_orchestrator"] --> B["openai_pool_orchestrator (主包)"]
+ A --> C["config"]
+ A --> D["run.py 启动入口"]
+ B --> E["server.py
FastAPI 服务"]
+ B --> F["register.py
注册引擎"]
+ B --> G["pool_maintainer.py
池维护"]
+ B --> H["mail_providers.py
邮箱适配层"]
+ B --> I["static/
Web 前端"]
+ E --> F
+ E --> G
+ E --> H
+ F --> H
+
+ click B "./openai_pool_orchestrator/CLAUDE.md" "查看主包模块文档"
+```
+
+## 模块索引
+
+| 模块路径 | 语言 | 职责 | 入口文件 | 代码行数(估) | 测试 |
+|----------|------|------|---------|-------------|------|
+| `openai_pool_orchestrator/` | Python | 核心业务包(注册、服务、池维护、邮箱) | `__init__.py` / `__main__.py` | ~5800+ | 无 |
+| `openai_pool_orchestrator/static/` | HTML/CSS/JS | Web 可视化界面 | `index.html` | ~2500+ | 无 |
+| `config/` | JSON | 配置模板 | `sync_config.example.json` | 55 | N/A |
+
+## 运行与开发
+
+### 安装依赖
+
+```bash
+pip install -r requirements.txt
+pip install -e . # 可编辑安装,注册 openai-pool 命令
+```
+
+### 启动方式
+
+```bash
+# Web 服务模式(推荐)
+python run.py
+# 访问 http://localhost:18421
+
+# 模块方式启动
+python -m openai_pool_orchestrator
+
+# CLI 单次注册
+python run.py --cli --proxy http://127.0.0.1:7897 --once
+
+# Docker
+docker compose up -d
+```
+
+### 配置
+
+1. 复制 `config/sync_config.example.json` 为 `data/sync_config.json`
+2. 在 Web UI 配置中心或直接编辑 JSON 填入敏感信息(代理、平台地址、Token、邮箱提供商配置)
+3. 运行时数据持久化到 `data/` 目录
+
+### Docker 部署
+
+```bash
+docker compose up -d
+# 数据卷映射:/opt/openai-pool/data -> /app/data, /opt/openai-pool/config -> /app/config
+# 使用 host 网络模式,端口 18421
+```
+
+### 语法检查
+
+```bash
+python -m compileall openai_pool_orchestrator
+```
+
+## 测试策略
+
+**当前状态**:项目尚未建立测试套件。
+
+**建议**:
+- 测试目录:`tests/`,文件命名 `test_*.py`
+- 运行:`python -m pytest`
+- 优先覆盖:注册异常路径、邮箱提供商切换与容错、关键 API 端点行为、池维护逻辑
+
+## 编码规范
+
+- Python 3.10+ 语法,遵循 PEP 8,4 空格缩进
+- 命名:模块/函数/变量 `snake_case`,类 `PascalCase`,常量 `UPPER_SNAKE_CASE`
+- `server.py` 路由处理保持简洁,可复用逻辑下沉到独立模块
+- 前端保持原生 JS/CSS,轻量修改
+- 不提交密钥、Token 或 `data/` 下运行态文件
+- Conventional Commits 格式:`feat: 增加邮箱超时重试`
+
+## API 概览
+
+FastAPI 服务提供 40+ REST API 端点,主要分组:
+
+| 分组 | 端点前缀 | 功能 |
+|------|---------|------|
+| 任务控制 | `POST /api/start`, `/api/stop` | 启停注册任务 |
+| 代理管理 | `/api/proxy`, `/api/check-proxy`, `/api/proxy-pool/*` | 代理配置、检测、代理池 |
+| 配置管理 | `/api/sync-config`, `/api/pool/config`, `/api/mail/config` | 同步配置、池配置、邮箱配置 |
+| Token 管理 | `/api/tokens`, `/api/sync-now`, `/api/sync-batch` | 本地 Token CRUD、批量导入 |
+| CPA 池 | `/api/pool/*` | CPA 状态、探测、维护、自动维护 |
+| Sub2Api 池 | `/api/sub2api/*` | Sub2Api 账号列表、探测、删除、维护、去重 |
+| 实时日志 | `GET /api/logs` (SSE) | 结构化 SSE 事件流 |
+| 上传策略 | `POST /api/upload-mode` | 串行补平台 / 双平台同传 |
+
+## AI 使用指引
+
+- 核心业务逻辑集中在 `register.py`(~1600 行)和 `server.py`(~3500 行),修改前请仔细理解上下文
+- `server.py` 中 `TaskState` 类管理全局任务状态与多 Worker 运行时快照
+- 邮箱提供商通过 `MailProvider` 抽象基类 + 工厂模式扩展,新增提供商需实现 `create_mailbox()` 和 `wait_for_otp()`
+- 池维护器分 `PoolMaintainer`(CPA)和 `Sub2ApiMaintainer`(Sub2Api),各自独立
+- 配置通过内存 + `data/sync_config.json` 双层持久化,读写有锁保护
+- 前端通过 SSE 接收结构化事件(`task_snapshot`、`runtime_snapshot`、`stats`、`log` 等)
+
+## 变更记录 (Changelog)
+
+| 时间 | 操作 | 说明 |
+|------|------|------|
+| 2026-03-18 09:19:57 | 初始扫描 | 首次生成 CLAUDE.md,覆盖全部源文件 |
diff --git a/Dockerfile b/Dockerfile
new file mode 100755
index 0000000..a1a6375
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,36 @@
+# ========================================
+# OpenAI Pool Orchestrator Docker 镜像
+# ========================================
+FROM python:3.12-slim
+
+# 禁用缓冲,让 Python 日志立即输出到 docker logs 终端
+ENV PYTHONUNBUFFERED=1
+
+# 系统依赖(curl-cffi 编译需要)
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ gcc g++ make curl libssl-dev libffi-dev \
+ nodejs npm yarn && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# 先拷贝依赖清单
+COPY requirements.txt pyproject.toml ./
+RUN pip install --no-cache-dir -r requirements.txt && \
+ pip install --no-cache-dir -e .
+
+# 拷贝项目全部代码
+COPY . .
+
+# 再次以可编辑模式安装,确保 static 资源被正确注册
+RUN pip install --no-cache-dir -e .
+
+# 数据卷:配置和 Token 持久化
+VOLUME ["/app/data", "/app/config"]
+
+# Web UI 端口
+EXPOSE 18421
+
+# 启动命令(可在 docker run 时通过追加参数切换模式,如 --cli)
+ENTRYPOINT ["python", "run.py"]
diff --git a/LICENSE b/LICENSE
new file mode 100755
index 0000000..530c892
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 OpenAI Pool Orchestrator Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/config/sync_config.example.json b/config/sync_config.example.json
new file mode 100755
index 0000000..69a6e44
--- /dev/null
+++ b/config/sync_config.example.json
@@ -0,0 +1,54 @@
+{
+ "proxy": "",
+ "auto_register": false,
+ "mail_providers": [
+ "mailtm"
+ ],
+ "mail_provider_configs": {
+ "mailtm": {
+ "api_base": "https://api.mail.tm"
+ },
+ "duckmail": {
+ "api_base": "https://api.duckmail.sbs"
+ },
+ "moemail": {
+ "api_base": "",
+ "api_key": ""
+ },
+ "cloudflare_temp_email": {
+ "api_base": "cloudflare worker后端密码,不要弄成前端的",
+ "admin_password": "管理员密码",
+ "domain": "xxx.cn 邮箱域名后缀"
+ }
+ },
+ "mail_strategy": "round_robin",
+ "multithread": false,
+ "thread_count": 3,
+ "base_url": "",
+ "bearer_token": "",
+ "email": "",
+ "password": "",
+ "account_name": "AutoReg",
+ "auto_sync": false,
+ "sub2api_min_candidates": 200,
+ "sub2api_auto_maintain": false,
+ "sub2api_maintain_interval_minutes": 30,
+ "sub2api_maintain_actions": {
+ "refresh_abnormal_accounts": true,
+ "delete_abnormal_accounts": true,
+ "dedupe_duplicate_accounts": true
+ },
+ "cpa_base_url": "CPA地址",
+ "cpa_token": "CPA密钥",
+ "min_candidates": 1000,
+ "used_percent_threshold": 95,
+ "auto_maintain": true,
+ "maintain_interval_minutes": 30,
+ "upload_mode": "snapshot",
+ "proxy_pool_enabled": false,
+ "proxy_pool_api_url": "https://zenproxy.top/api/fetch",
+ "proxy_pool_auth_mode": "header",
+ "proxy_pool_api_key": "使用自己的Key",
+ "proxy_pool_count": 1,
+ "proxy_pool_country": "US"
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100755
index 0000000..d103617
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,10 @@
+services:
+ openai-pool:
+ build: .
+ image: openai-pool-orchestrator:latest
+ container_name: openai-pool
+ restart: unless-stopped
+ network_mode: host
+ volumes:
+ - /opt/openai-pool/data:/app/data
+ - /opt/openai-pool/config:/app/config
diff --git a/openai_pool_orchestrator/CLAUDE.md b/openai_pool_orchestrator/CLAUDE.md
new file mode 100644
index 0000000..a25a3a3
--- /dev/null
+++ b/openai_pool_orchestrator/CLAUDE.md
@@ -0,0 +1,193 @@
+[根目录](../CLAUDE.md) > **openai_pool_orchestrator (主包)**
+
+# openai_pool_orchestrator -- 核心业务包
+
+## 模块职责
+
+项目唯一的 Python 包,包含全部后端业务逻辑:OpenAI 账号自动注册引擎、FastAPI REST API 服务、CPA / Sub2Api 双平台账号池维护、多邮箱提供商适配层,以及嵌入式 Web 前端静态资源。
+
+## 入口与启动
+
+| 入口 | 路径 | 说明 |
+|------|------|------|
+| Web 服务 | `__main__.py` -> `main()` | 启动 Uvicorn 服务器,监听 `0.0.0.0:18421`,加载 `server.app` |
+| CLI 注册 | `register.py` -> `main()` | 命令行单次/循环注册模式,通过 argparse 接收参数 |
+| 快捷脚本 | `../run.py` | 根据 `--cli` 标志分派到上述两种入口 |
+| pip 命令 | `openai-pool` | pyproject.toml 注册的控制台入口,指向 `__main__:main` |
+
+## 对外接口
+
+### REST API(server.py)
+
+FastAPI 应用提供 40+ 端点,核心分组:
+
+**任务控制**
+- `POST /api/start` -- 启动注册任务(支持多线程、目标数量、代理配置)
+- `POST /api/stop` -- 停止运行中的任务
+
+**代理管理**
+- `POST /api/proxy/save` / `GET /api/proxy` -- 保存/获取代理地址
+- `POST /api/check-proxy` -- 检测代理可用性
+- `GET /api/proxy-pool/config` / `POST /api/proxy-pool/config` -- 代理池配置
+- `POST /api/proxy-pool/test` -- 测试代理池取号
+
+**配置管理**
+- `GET /api/sync-config` / `POST /api/sync-config` -- Sub2Api 同步配置
+- `GET /api/pool/config` / `POST /api/pool/config` -- CPA 池配置
+- `GET /api/mail/config` / `POST /api/mail/config` -- 邮箱提供商配置
+- `POST /api/upload-mode` -- 上传策略切换(snapshot / decoupled)
+
+**Token 管理**
+- `GET /api/tokens` -- 列出本地 Token 文件
+- `DELETE /api/tokens/{filename}` -- 删除单个 Token
+- `POST /api/sync-now` -- 单个 Token 导入 Sub2Api
+- `POST /api/sync-batch` -- 批量导入
+
+**CPA 池**
+- `GET /api/pool/status` -- CPA 池状态
+- `POST /api/pool/check` -- 探测 CPA 池
+- `POST /api/pool/maintain` -- 执行 CPA 维护
+- `POST /api/pool/auto` -- 开关自动维护
+
+**Sub2Api 池**
+- `GET /api/sub2api/accounts` -- 分页账号列表(支持状态/关键字筛选)
+- `POST /api/sub2api/accounts/probe` -- 批量测活
+- `POST /api/sub2api/accounts/delete` -- 批量删除
+- `POST /api/sub2api/accounts/handle-exception` -- 异常处理
+- `GET /api/sub2api/pool/status` -- Sub2Api 池状态
+- `POST /api/sub2api/pool/maintain` -- Sub2Api 维护
+- `POST /api/sub2api/pool/dedupe` -- 重复账号去重
+
+**实时通信**
+- `GET /api/logs` -- SSE 事件流(结构化事件:task_snapshot、runtime_snapshot、stats、log、pool_status 等)
+
+### SSE 事件类型
+
+| 事件类型 | 说明 |
+|---------|------|
+| `task_snapshot` | 任务全局状态快照 |
+| `runtime_snapshot` | 多 Worker 运行时明细 |
+| `stats` | 成功/失败计数统计 |
+| `log` | 实时日志消息 |
+| `pool_status` | CPA/Sub2Api 池状态变化 |
+
+## 关键依赖与配置
+
+### Python 依赖
+
+| 包 | 用途 |
+|----|------|
+| `fastapi` >= 0.110 | Web 框架 |
+| `uvicorn[standard]` >= 0.27 | ASGI 服务器 |
+| `curl-cffi` >= 0.6 | TLS 指纹伪装 HTTP 客户端(模拟 Chrome) |
+| `aiohttp` >= 3.9 | 异步 HTTP(池维护探测) |
+| `requests` >= 2.31 | 同步 HTTP 客户端 |
+| `pydantic` | 请求体校验(FastAPI 内置) |
+
+### 配置文件
+
+- `data/sync_config.json` -- 运行时配置(从 `config/sync_config.example.json` 生成)
+- `data/state.json` -- 累计成功/失败计数持久化
+- `data/tokens/` -- 注册获取的 Token JSON 文件
+
+### 核心配置项
+
+| 配置键 | 类型 | 说明 |
+|--------|------|------|
+| `proxy` | str | 固定代理地址 |
+| `auto_register` | bool | 池不足时自动注册 |
+| `mail_providers` | list[str] | 启用的邮箱提供商列表 |
+| `mail_strategy` | str | 邮箱路由策略:round_robin / random / failover |
+| `base_url` / `email` / `password` | str | Sub2Api 平台连接信息 |
+| `cpa_base_url` / `cpa_token` | str | CPA 平台连接信息 |
+| `upload_mode` | str | 上传策略:snapshot(串行)/ decoupled(双平台同传)|
+| `proxy_pool_*` | mixed | 代理池 API 配置 |
+| `sub2api_maintain_actions` | dict | Sub2Api 维护动作开关 |
+
+## 数据模型
+
+### Token 文件格式(data/tokens/*.json)
+
+注册成功后保存的 Token 文件包含 OAuth 凭证信息,用于后续导入平台。
+
+### TaskState(server.py)
+
+全局单例,管理注册任务的完整生命周期:
+- 多 Worker 线程管理与运行时快照
+- SSE 事件订阅/分发
+- 成功/失败计数与平台上传统计
+- 注册步骤追踪:check_proxy -> create_email -> oauth_init -> sentinel -> signup -> send_otp -> wait_otp -> verify_otp -> create_account -> workspace -> get_token
+
+### PoolMaintainer(pool_maintainer.py)
+
+CPA 平台维护器:
+- `fetch_auth_files()` -- 获取全部 auth 文件
+- `get_pool_status()` -- 池状态统计
+- `probe_accounts_async()` -- 异步批量探测账号有效性
+
+### Sub2ApiMaintainer(pool_maintainer.py)
+
+Sub2Api 平台维护器:
+- `list_accounts()` / `_list_all_accounts()` -- 分页/全量列出账号
+- `get_dashboard_stats()` -- 仪表盘统计
+- 自动 token 刷新(401 -> re-login)
+
+### MailProvider 体系(mail_providers.py)
+
+抽象基类 + 4 种实现:
+
+| 类名 | 提供商 | 认证方式 |
+|------|--------|---------|
+| `MailTmProvider` | Mail.tm | Bearer Token |
+| `MoeMailProvider` | MoeMail | API Key |
+| `DuckMailProvider` | DuckMail | Bearer Token |
+| `CloudflareTempEmailProvider` | Cloudflare Workers | JWT + Admin Password |
+
+`MultiMailRouter` -- 线程安全的多提供商路由器,支持轮询/随机/容错策略。
+
+## 测试与质量
+
+- **测试**:当前无测试套件
+- **类型检查**:无 mypy/pyright 配置
+- **Lint**:无 ruff/flake8 配置
+- **CI/CD**:无
+
+**建议优先覆盖的测试场景**:
+1. `mail_providers.py` -- 各提供商创建邮箱与 OTP 轮询的异常路径
+2. `register.py` -- OAuth 流程各步骤的错误处理与重试
+3. `server.py` -- 核心 API 端点的请求/响应校验
+4. `pool_maintainer.py` -- 池状态计算与维护动作
+
+## 常见问题 (FAQ)
+
+**Q: server.py 文件为何如此庞大?**
+A: 当前 server.py 约 3500+ 行,包含了全部 REST API 路由、TaskState 状态管理、平台交互逻辑、自动维护定时器等。建议后续拆分为路由模块、任务管理模块、平台交互模块等。
+
+**Q: register.py 中 curl-cffi 的作用?**
+A: 使用 `curl_cffi.requests` 而非标准 `requests`,可伪装 Chrome TLS 指纹,避免被 OpenAI / Cloudflare 反爬检测拦截。
+
+**Q: 如何新增邮箱提供商?**
+A: 继承 `MailProvider` 基类,实现 `create_mailbox()` 和 `wait_for_otp()` 方法,然后在 `create_provider_by_name()` 工厂函数中注册。
+
+**Q: 双平台上传策略的区别?**
+A: `snapshot` 模式按顺序先补 CPA 再补 Sub2Api;`decoupled` 模式让单个账号同时上传到两个平台。
+
+## 相关文件清单
+
+| 文件 | 行数(估) | 说明 |
+|------|----------|------|
+| `__init__.py` | 29 | 包初始化,路径常量定义 |
+| `__main__.py` | 119 | Uvicorn 启动与优雅关闭 |
+| `server.py` | 3550+ | FastAPI 服务,全部 API 与任务状态 |
+| `register.py` | 1600+ | OpenAI OAuth 注册引擎 |
+| `pool_maintainer.py` | 800+ | CPA / Sub2Api 池维护 |
+| `mail_providers.py` | 809 | 邮箱提供商抽象与 4 种实现 |
+| `static/index.html` | 695 | Web UI 页面结构 |
+| `static/app.js` | 2200+ | 前端交互逻辑 |
+| `static/style.css` | 2800+ | iOS Flat Design 样式 |
+
+## 变更记录 (Changelog)
+
+| 时间 | 操作 | 说明 |
+|------|------|------|
+| 2026-03-18 09:19:57 | 初始扫描 | 首次生成模块级 CLAUDE.md |
diff --git a/openai_pool_orchestrator/__init__.py b/openai_pool_orchestrator/__init__.py
new file mode 100755
index 0000000..5abdf1a
--- /dev/null
+++ b/openai_pool_orchestrator/__init__.py
@@ -0,0 +1,28 @@
+"""
+OpenAI Pool Orchestrator
+========================
+自动化 OpenAI 账号注册、Token 管理与多平台账号池维护工具。
+"""
+
+__version__ = "2.0.0"
+__author__ = "OpenAI Pool Orchestrator Contributors"
+
+import os
+from pathlib import Path
+
+# 项目根目录(包目录的上一级)
+PACKAGE_DIR = Path(__file__).parent
+PROJECT_ROOT = PACKAGE_DIR.parent
+
+# 运行时数据目录
+DATA_DIR = PROJECT_ROOT / "data"
+DATA_DIR.mkdir(exist_ok=True)
+
+TOKENS_DIR = DATA_DIR / "tokens"
+TOKENS_DIR.mkdir(exist_ok=True)
+
+CONFIG_FILE = DATA_DIR / "sync_config.json"
+STATE_FILE = DATA_DIR / "state.json"
+
+# 前端静态文件目录
+STATIC_DIR = PACKAGE_DIR / "static"
diff --git a/openai_pool_orchestrator/__main__.py b/openai_pool_orchestrator/__main__.py
new file mode 100755
index 0000000..49675c5
--- /dev/null
+++ b/openai_pool_orchestrator/__main__.py
@@ -0,0 +1,119 @@
+"""
+允许通过 python -m openai_pool_orchestrator 启动服务。
+"""
+
+import os
+import sys
+import threading
+from typing import Callable
+
+import uvicorn
+
+from . import __version__
+
+GRACEFUL_SHUTDOWN_TIMEOUT = 5
+FORCE_EXIT_TIMEOUT = 3
+
+
+def _request_server_shutdown(
+ server: uvicorn.Server,
+ notify_shutdown: Callable[[], None],
+ *,
+ force: bool = False,
+ message: str | None = None,
+) -> None:
+ if message:
+ print(f"\n{message}")
+ server.should_exit = True
+ if force:
+ server.force_exit = True
+ notify_shutdown()
+
+
+def _install_windows_ctrl_handler(
+ server: uvicorn.Server,
+ notify_shutdown: Callable[[], None],
+):
+ import ctypes
+
+ kernel32 = ctypes.windll.kernel32
+ shutting_down = threading.Event()
+ shutdown_finished = threading.Event()
+
+ def _force_exit_after_timeout() -> None:
+ if shutdown_finished.wait(GRACEFUL_SHUTDOWN_TIMEOUT):
+ return
+ _request_server_shutdown(
+ server,
+ notify_shutdown,
+ force=True,
+ message="正在强制退出...",
+ )
+ if shutdown_finished.wait(FORCE_EXIT_TIMEOUT):
+ return
+ os._exit(130)
+
+ def _ctrl_handler(ctrl_type):
+ # CTRL_C_EVENT = 0, CTRL_BREAK_EVENT = 1
+ if ctrl_type not in (0, 1):
+ return False
+
+ if shutting_down.is_set():
+ _request_server_shutdown(
+ server,
+ notify_shutdown,
+ force=True,
+ message=None if server.force_exit else "正在强制退出...",
+ )
+ return True
+
+ shutting_down.set()
+ _request_server_shutdown(server, notify_shutdown, message="正在退出...")
+ threading.Thread(target=_force_exit_after_timeout, daemon=True).start()
+ return True
+
+ handler_routine = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_uint)
+ handler = handler_routine(_ctrl_handler)
+ kernel32.SetConsoleCtrlHandler(handler, True)
+
+ def _cleanup() -> None:
+ shutdown_finished.set()
+ try:
+ kernel32.SetConsoleCtrlHandler(handler, False)
+ except Exception:
+ pass
+
+ return _cleanup
+
+
+def main() -> None:
+ print("=" * 50)
+ print(f" OpenAI Pool Orchestrator v{__version__}")
+ print(" 访问: http://localhost:18421")
+ print(" 按 Ctrl+C 可退出")
+ print("=" * 50)
+
+ from .server import app, request_service_shutdown
+
+ config = uvicorn.Config(
+ app,
+ host="0.0.0.0",
+ port=18421,
+ log_level="warning",
+ timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
+ )
+ server = uvicorn.Server(config)
+
+ cleanup_ctrl_handler = None
+ if sys.platform == "win32":
+ cleanup_ctrl_handler = _install_windows_ctrl_handler(server, request_service_shutdown)
+
+ try:
+ server.run()
+ finally:
+ if cleanup_ctrl_handler is not None:
+ cleanup_ctrl_handler()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/openai_pool_orchestrator/mail_providers.py b/openai_pool_orchestrator/mail_providers.py
new file mode 100755
index 0000000..3965872
--- /dev/null
+++ b/openai_pool_orchestrator/mail_providers.py
@@ -0,0 +1,812 @@
+"""
+MailProvider 抽象层
+支持 Mail.tm / MoeMail / DuckMail / 自定义兼容 API
+"""
+
+from __future__ import annotations
+
+import itertools
+import logging
+import random
+import re
+import secrets
+import string
+import time
+import threading
+from abc import ABC, abstractmethod
+from typing import Any, Dict, List, Optional, Tuple, Callable
+
+import requests as _requests
+from requests.adapters import HTTPAdapter
+import urllib3
+from urllib3.exceptions import InsecureRequestWarning
+from urllib3.util.retry import Retry
+
+logger = logging.getLogger(__name__)
+urllib3.disable_warnings(InsecureRequestWarning)
+
+
+def _normalize_proxy_url(proxy: str) -> str:
+ value = str(proxy or "").strip()
+ if not value:
+ return ""
+ if "://" in value:
+ return value
+ if ":" in value:
+ return f"http://{value}"
+ return ""
+
+
+class _ProxyAwareSession(_requests.Session):
+ def __init__(
+ self,
+ proxy: str = "",
+ proxy_selector: Optional[Callable[[], str]] = None,
+ ):
+ super().__init__()
+ self._default_proxy = _normalize_proxy_url(proxy)
+ self._proxy_selector = proxy_selector
+
+ def request(self, method, url, **kwargs):
+ selected_proxy = ""
+ if self._proxy_selector:
+ try:
+ selected_proxy = _normalize_proxy_url(self._proxy_selector() or "")
+ except Exception:
+ selected_proxy = ""
+ if not selected_proxy:
+ selected_proxy = self._default_proxy
+ base_kwargs = dict(kwargs)
+ if selected_proxy and "proxies" not in base_kwargs:
+ base_kwargs["proxies"] = {"http": selected_proxy, "https": selected_proxy}
+ try:
+ return super().request(method, url, **base_kwargs)
+ except Exception:
+ # 动态代理失败时,自动回退固定代理(若有)
+ if (
+ selected_proxy
+ and self._default_proxy
+ and selected_proxy != self._default_proxy
+ and "proxies" not in kwargs
+ ):
+ fallback_kwargs = dict(kwargs)
+ fallback_kwargs["proxies"] = {"http": self._default_proxy, "https": self._default_proxy}
+ return super().request(method, url, **fallback_kwargs)
+ raise
+
+
+def _build_session(proxy: str = "", proxy_selector: Optional[Callable[[], str]] = None) -> _requests.Session:
+ s = _ProxyAwareSession(proxy, proxy_selector)
+ retry_total = 0 if proxy_selector else 2
+ retry = Retry(
+ total=retry_total,
+ connect=retry_total,
+ read=retry_total,
+ status=retry_total,
+ backoff_factor=0.2,
+ status_forcelist=[429, 500, 502, 503, 504],
+ )
+ adapter = HTTPAdapter(max_retries=retry)
+ s.mount("https://", adapter)
+ s.mount("http://", adapter)
+ fixed_proxy = _normalize_proxy_url(proxy)
+ if fixed_proxy and not proxy_selector:
+ s.proxies = {"http": fixed_proxy, "https": fixed_proxy}
+ return s
+
+
+def _extract_code(content: str) -> Optional[str]:
+ if not content:
+ return None
+ m = re.search(r"background-color:\s*#F3F3F3[^>]*>[\s\S]*?(\d{6})[\s\S]*?
", content)
+ if m:
+ return m.group(1)
+ for pat in [
+ r"Verification code:?\s*(\d{6})",
+ r"code is\s*(\d{6})",
+ r"Subject:.*?(\d{6})",
+ r">\s*(\d{6})\s*<",
+ r"(? Tuple[str, str]:
+ """返回 (email, auth_credential),auth_credential 是 bearer token 或 email_id"""
+
+ @abstractmethod
+ def wait_for_otp(
+ self,
+ auth_credential: str,
+ email: str,
+ proxy: str = "",
+ proxy_selector: Optional[Callable[[], str]] = None,
+ timeout: int = 120,
+ stop_event: Optional[threading.Event] = None,
+ ) -> str:
+ """轮询获取6位验证码,超时返回空字符串"""
+
+ def test_connection(self, proxy: str = "") -> Tuple[bool, str]:
+ """测试 API 连通性,返回 (success, message)"""
+ try:
+ email, cred = self.create_mailbox(proxy)
+ if email and cred:
+ return True, f"成功创建测试邮箱: {email}"
+ return False, "创建邮箱失败,请检查配置"
+ except Exception as e:
+ return False, f"连接失败: {e}"
+
+ def close(self):
+ pass
+
+
+# ==================== Mail.tm ====================
+
+class MailTmProvider(MailProvider):
+ def __init__(self, api_base: str = "https://api.mail.tm"):
+ self.api_base = api_base.rstrip("/")
+
+ def _headers(self, token: str = "", use_json: bool = False) -> Dict[str, str]:
+ h: Dict[str, str] = {"Accept": "application/json"}
+ if use_json:
+ h["Content-Type"] = "application/json"
+ if token:
+ h["Authorization"] = f"Bearer {token}"
+ return h
+
+ def _get_domains(self, session: _requests.Session) -> List[str]:
+ resp = session.get(f"{self.api_base}/domains", headers=self._headers(), timeout=15, verify=False)
+ if resp.status_code != 200:
+ return []
+ data = resp.json()
+ items = data if isinstance(data, list) else (data.get("hydra:member") or data.get("items") or [])
+ domains = []
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ domain = str(item.get("domain") or "").strip()
+ if domain and item.get("isActive", True) and not item.get("isPrivate", False):
+ domains.append(domain)
+ return domains
+
+ def create_mailbox(
+ self,
+ proxy: str = "",
+ proxy_selector: Optional[Callable[[], str]] = None,
+ ) -> Tuple[str, str]:
+ with _build_session(proxy, proxy_selector) as session:
+ domains = self._get_domains(session)
+ if not domains:
+ return "", ""
+ # 优先使用 duckmail.sbs 主域名,避免临时域名被 OpenAI 封禁
+ _preferred = [d for d in domains if "duckmail" in d.lower()]
+ domain = random.choice(_preferred) if _preferred else random.choice(domains)
+
+ for _ in range(5):
+ local = f"oc{secrets.token_hex(5)}"
+ email = f"{local}@{domain}"
+ password = secrets.token_urlsafe(18)
+
+ resp = session.post(
+ f"{self.api_base}/accounts",
+ headers=self._headers(use_json=True),
+ json={"address": email, "password": password},
+ timeout=15, verify=False,
+ )
+ if resp.status_code not in (200, 201):
+ continue
+
+ token_resp = session.post(
+ f"{self.api_base}/token",
+ headers=self._headers(use_json=True),
+ json={"address": email, "password": password},
+ timeout=15, verify=False,
+ )
+ if token_resp.status_code == 200:
+ token = str(token_resp.json().get("token") or "").strip()
+ if token:
+ return email, token
+ return "", ""
+
+ def wait_for_otp(
+ self,
+ auth_credential: str,
+ email: str,
+ proxy: str = "",
+ proxy_selector: Optional[Callable[[], str]] = None,
+ timeout: int = 120,
+ stop_event: Optional[threading.Event] = None,
+ ) -> str:
+ with _build_session(proxy, proxy_selector) as session:
+ seen_ids: set = set()
+ start = time.time()
+
+ while time.time() - start < timeout:
+ if stop_event and stop_event.is_set():
+ return ""
+ try:
+ resp = session.get(
+ f"{self.api_base}/messages",
+ headers=self._headers(token=auth_credential),
+ timeout=15, verify=False,
+ )
+ if resp.status_code != 200:
+ time.sleep(3)
+ continue
+
+ data = resp.json()
+ messages = data if isinstance(data, list) else (
+ data.get("hydra:member") or data.get("messages") or []
+ )
+
+ for msg in messages:
+ if not isinstance(msg, dict):
+ continue
+ msg_id = str(msg.get("id") or msg.get("@id") or "").strip()
+ if not msg_id or msg_id in seen_ids:
+ continue
+
+ if msg_id.startswith("/messages/"):
+ msg_id = msg_id.split("/")[-1]
+
+ detail_resp = session.get(
+ f"{self.api_base}/messages/{msg_id}",
+ headers=self._headers(token=auth_credential),
+ timeout=15, verify=False,
+ )
+ if detail_resp.status_code != 200:
+ continue
+ seen_ids.add(msg_id)
+
+ mail_data = detail_resp.json()
+ sender = str(((mail_data.get("from") or {}).get("address") or "")).lower()
+ subject = str(mail_data.get("subject") or "")
+ intro = str(mail_data.get("intro") or "")
+ text = str(mail_data.get("text") or "")
+ html = mail_data.get("html") or ""
+ if isinstance(html, list):
+ html = "\n".join(str(x) for x in html)
+ content = "\n".join([subject, intro, text, str(html)])
+
+ if "openai" not in sender and "openai" not in content.lower():
+ continue
+
+ code = _extract_code(content)
+ if code:
+ return code
+ except Exception as exc:
+ logger.warning("Mail.tm 轮询验证码失败: %s", exc)
+ time.sleep(3)
+ return ""
+
+
+# ==================== MoeMail ====================
+
+class MoeMailProvider(MailProvider):
+ def __init__(self, api_base: str, api_key: str):
+ self.api_base = api_base.rstrip("/")
+ self.api_key = api_key
+
+ def _headers(self) -> Dict[str, str]:
+ return {"X-API-Key": self.api_key}
+
+ def _get_domain(self, session: _requests.Session) -> Optional[str]:
+ try:
+ resp = session.get(
+ f"{self.api_base}/api/config",
+ headers=self._headers(), timeout=10, verify=False,
+ )
+ if resp.status_code == 200:
+ data = resp.json()
+ domains_str = data.get("emailDomains", "")
+ if domains_str:
+ domains = [d.strip() for d in domains_str.split(",") if d.strip()]
+ if domains:
+ return random.choice(domains)
+ except Exception as exc:
+ logger.warning("MoeMail 读取域名配置失败: %s", exc)
+ return None
+
+ def create_mailbox(
+ self,
+ proxy: str = "",
+ proxy_selector: Optional[Callable[[], str]] = None,
+ ) -> Tuple[str, str]:
+ with _build_session(proxy, proxy_selector) as session:
+ domain = self._get_domain(session)
+ if not domain:
+ return "", ""
+
+ chars = string.ascii_lowercase + string.digits
+ prefix = "".join(random.choice(chars) for _ in range(random.randint(8, 13)))
+
+ try:
+ resp = session.post(
+ f"{self.api_base}/api/emails/generate",
+ json={"name": prefix, "domain": domain, "expiryTime": 0},
+ headers=self._headers(), timeout=15, verify=False,
+ )
+ if resp.status_code not in (200, 201):
+ return "", ""
+ data = resp.json()
+ email_id = data.get("id")
+ email = data.get("email")
+ if email_id and email:
+ return email, str(email_id)
+ except Exception as exc:
+ logger.warning("MoeMail 创建邮箱失败: %s", exc)
+ return "", ""
+
+ def wait_for_otp(
+ self,
+ auth_credential: str,
+ email: str,
+ proxy: str = "",
+ proxy_selector: Optional[Callable[[], str]] = None,
+ timeout: int = 120,
+ stop_event: Optional[threading.Event] = None,
+ ) -> str:
+ with _build_session(proxy, proxy_selector) as session:
+ email_id = auth_credential
+ start = time.time()
+
+ while time.time() - start < timeout:
+ if stop_event and stop_event.is_set():
+ return ""
+ try:
+ resp = session.get(
+ f"{self.api_base}/api/emails/{email_id}",
+ headers=self._headers(), timeout=15, verify=False,
+ )
+ if resp.status_code == 200:
+ messages = resp.json().get("messages") or []
+ for msg in messages:
+ if not isinstance(msg, dict):
+ continue
+ msg_id = msg.get("id")
+ if not msg_id:
+ continue
+ detail_resp = session.get(
+ f"{self.api_base}/api/emails/{email_id}/{msg_id}",
+ headers=self._headers(), timeout=15, verify=False,
+ )
+ if detail_resp.status_code == 200:
+ detail = detail_resp.json()
+ msg_obj = detail.get("message") or {}
+ content = msg_obj.get("content") or msg_obj.get("html") or ""
+ if not content:
+ content = detail.get("text") or detail.get("html") or ""
+ code = _extract_code(content)
+ if code:
+ return code
+ except Exception as exc:
+ logger.warning("MoeMail 轮询验证码失败: %s", exc)
+ time.sleep(3)
+ return ""
+
+
+# ==================== DuckMail ====================
+
+class DuckMailProvider(MailProvider):
+ def __init__(self, api_base: str = "https://api.duckmail.sbs", bearer_token: str = ""):
+ self.api_base = api_base.rstrip("/")
+ self.bearer_token = bearer_token
+
+ def _auth_headers(self, token: str = "") -> Dict[str, str]:
+ h: Dict[str, str] = {"Accept": "application/json"}
+ if token:
+ h["Authorization"] = f"Bearer {token}"
+ return h
+
+ def create_mailbox(
+ self,
+ proxy: str = "",
+ proxy_selector: Optional[Callable[[], str]] = None,
+ ) -> Tuple[str, str]:
+ with _build_session(proxy, proxy_selector) as session:
+ headers: Dict[str, str] = {"Content-Type": "application/json", "Accept": "application/json"}
+ if self.bearer_token:
+ headers["Authorization"] = f"Bearer {self.bearer_token}"
+
+ try:
+ domains_resp = session.get(f"{self.api_base}/domains", headers={"Accept": "application/json"}, timeout=15, verify=False)
+ if domains_resp.status_code != 200:
+ return "", ""
+ data = domains_resp.json()
+ items = data if isinstance(data, list) else (data.get("hydra:member") or [])
+ domains = [str(i.get("domain") or "") for i in items if isinstance(i, dict) and i.get("domain") and i.get("isActive", True)]
+ if not domains:
+ return "", ""
+ # 优先使用 duckmail.sbs 主域名,避免临时域名被 OpenAI 封禁
+ _preferred = [d for d in domains if "duckmail" in d.lower()]
+ domain = random.choice(_preferred) if _preferred else random.choice(domains)
+
+ local = f"oc{secrets.token_hex(5)}"
+ email = f"{local}@{domain}"
+ password = secrets.token_urlsafe(18)
+
+ resp = session.post(
+ f"{self.api_base}/accounts",
+ json={"address": email, "password": password},
+ headers=headers, timeout=30, verify=False,
+ )
+ if resp.status_code not in (200, 201):
+ return "", ""
+
+ time.sleep(0.5)
+ token_resp = session.post(
+ f"{self.api_base}/token",
+ json={"address": email, "password": password},
+ headers=headers, timeout=30, verify=False,
+ )
+ if token_resp.status_code == 200:
+ mail_token = token_resp.json().get("token")
+ if mail_token:
+ return email, str(mail_token)
+ except Exception as exc:
+ logger.warning("DuckMail 创建邮箱失败: %s", exc)
+ return "", ""
+
+ def wait_for_otp(
+ self,
+ auth_credential: str,
+ email: str,
+ proxy: str = "",
+ proxy_selector: Optional[Callable[[], str]] = None,
+ timeout: int = 120,
+ stop_event: Optional[threading.Event] = None,
+ ) -> str:
+ with _build_session(proxy, proxy_selector) as session:
+ seen_ids: set = set()
+ start = time.time()
+
+ while time.time() - start < timeout:
+ if stop_event and stop_event.is_set():
+ return ""
+ try:
+ resp = session.get(
+ f"{self.api_base}/messages",
+ headers=self._auth_headers(auth_credential),
+ timeout=30, verify=False,
+ )
+ if resp.status_code == 200:
+ data = resp.json()
+ messages = data.get("hydra:member") or data.get("member") or data.get("data") or []
+ for msg in (messages if isinstance(messages, list) else []):
+ if not isinstance(msg, dict):
+ continue
+ msg_id = msg.get("id") or msg.get("@id")
+ if not msg_id or msg_id in seen_ids:
+ continue
+ raw_id = str(msg_id).split("/")[-1] if str(msg_id).startswith("/") else str(msg_id)
+
+ detail_resp = session.get(
+ f"{self.api_base}/messages/{raw_id}",
+ headers=self._auth_headers(auth_credential),
+ timeout=30, verify=False,
+ )
+ if detail_resp.status_code == 200:
+ seen_ids.add(msg_id)
+ detail = detail_resp.json()
+ content = detail.get("text") or detail.get("html") or ""
+ code = _extract_code(content)
+ if code:
+ return code
+ except Exception as exc:
+ logger.warning("DuckMail 轮询验证码失败: %s", exc)
+ time.sleep(3)
+ return ""
+
+
+# ==================== Cloudflare Temp Email ====================
+
+class CloudflareTempEmailProvider(MailProvider):
+ def __init__(self, api_base: str = "", admin_password: str = "", domain: str = ""):
+ self.api_base = api_base.rstrip("/")
+ self.admin_password = admin_password
+ self.domain = str(domain).strip()
+ # 使用线程本地 token,避免多线程下邮箱 token 串用。
+ self._tls = threading.local()
+
+ def _get_random_domain(self) -> str:
+ if not self.domain:
+ return ""
+ # 尝试按照 JSON 数组解析
+ if self.domain.startswith("[") and self.domain.endswith("]"):
+ try:
+ import json
+ domain_list = json.loads(self.domain)
+ if isinstance(domain_list, list) and domain_list:
+ return random.choice([str(d).strip() for d in domain_list if str(d).strip()])
+ except Exception:
+ pass
+ # 按照逗号分隔解析
+ if "," in self.domain:
+ parts = [d.strip() for d in self.domain.split(",") if d.strip()]
+ if parts:
+ return random.choice(parts)
+ return self.domain
+
+ @staticmethod
+ def _message_matches_email(msg: Dict[str, Any], target_email: str) -> bool:
+ target = str(target_email or "").strip().lower()
+ if not target:
+ return True
+
+ def _extract_text_candidates(value: Any) -> List[str]:
+ out: List[str] = []
+ if isinstance(value, str):
+ out.append(value)
+ elif isinstance(value, dict):
+ for k in ("address", "email", "name", "value"):
+ if value.get(k):
+ out.extend(_extract_text_candidates(value.get(k)))
+ elif isinstance(value, list):
+ for item in value:
+ out.extend(_extract_text_candidates(item))
+ return out
+
+ candidates: List[str] = []
+ for key in ("to", "mailTo", "receiver", "receivers", "address", "email", "envelope_to"):
+ if key in msg:
+ candidates.extend(_extract_text_candidates(msg.get(key)))
+ if not candidates:
+ return True
+ target_lower = target.lower()
+ for raw in candidates:
+ text = str(raw or "").strip().lower()
+ if not text:
+ continue
+ if target_lower in text:
+ return True
+ return False
+
+ def create_mailbox(
+ self,
+ proxy: str = "",
+ proxy_selector: Optional[Callable[[], str]] = None,
+ ) -> Tuple[str, str]:
+ if not self.api_base or not self.admin_password or not self.domain:
+ return "", ""
+
+ with _build_session(proxy, proxy_selector) as session:
+ try:
+ # 生成5位字母 + 1-3位数字 + 1-3位字母的随机名
+ letters1 = ''.join(random.choices(string.ascii_lowercase, k=5))
+ numbers = ''.join(random.choices(string.digits, k=random.randint(1, 3)))
+ letters2 = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))
+ name = letters1 + numbers + letters2
+
+ target_domain = self._get_random_domain()
+ if not target_domain:
+ return "", ""
+
+ resp = session.post(
+ f"{self.api_base}/admin/new_address",
+ json={
+ "enablePrefix": True,
+ "name": name,
+ "domain": target_domain,
+ },
+ headers={
+ "x-admin-auth": self.admin_password,
+ "Content-Type": "application/json"
+ },
+ timeout=30, verify=False,
+ )
+ if resp.status_code == 200:
+ data = resp.json()
+ email = data.get("address")
+ jwt_token = data.get("jwt")
+ if email and jwt_token:
+ self._tls.jwt_token = jwt_token
+ return email, jwt_token
+ except Exception as exc:
+ logger.warning("Cloudflare 临时邮箱创建失败: %s", exc)
+ return "", ""
+
+ def wait_for_otp(
+ self,
+ auth_credential: str,
+ email: str,
+ proxy: str = "",
+ proxy_selector: Optional[Callable[[], str]] = None,
+ timeout: int = 120,
+ stop_event: Optional[threading.Event] = None,
+ ) -> str:
+ token = str(auth_credential or "").strip() or str(getattr(self._tls, "jwt_token", "") or "").strip()
+ if not token:
+ return ""
+ print(f"[CFMail] wait_for_otp 进入! email={email}, api_base={self.api_base}, jwt前16={token[:16] if token else 'EMPTY'}", flush=True)
+ with _build_session(proxy, proxy_selector) as session:
+ seen_ids: set = set()
+ start = time.time()
+ poll_count = 0
+
+ while time.time() - start < timeout:
+ if stop_event and stop_event.is_set():
+ print("[CFMail] stop_event 已触发,退出", flush=True)
+ return ""
+ try:
+ poll_count += 1
+ url = f"{self.api_base}/api/mails?limit=10&offset=0"
+ resp = session.get(
+ url,
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json"
+ },
+ timeout=30, verify=False,
+ )
+ print(f"[CFMail] 轮询#{poll_count} status={resp.status_code}, body前200={str(resp.text or '')[:200]}", flush=True)
+ if resp.status_code == 200:
+ try:
+ data = resp.json()
+ except Exception as je:
+ print(f"[CFMail] JSON解析失败: {je}", flush=True)
+ time.sleep(3)
+ continue
+ # API 返回字典 {"results": [...], "count": 0},需正确提取
+ if isinstance(data, dict):
+ messages = data.get("results") or []
+ elif isinstance(data, list):
+ messages = data
+ else:
+ messages = []
+ print(f"[CFMail] 解析到 {len(messages)} 条邮件", flush=True)
+ for msg in messages:
+ if not isinstance(msg, dict):
+ continue
+ if not self._message_matches_email(msg, email):
+ continue
+ msg_id = msg.get("id")
+ if not msg_id or msg_id in seen_ids:
+ continue
+ seen_ids.add(msg_id)
+
+ content = msg.get("text") or msg.get("html") or ""
+ # Cloudflare Temp Email 将邮件原文放在 raw 字段(MIME 格式)
+ if not content and msg.get("raw"):
+ try:
+ import email as _email_mod
+ from email import policy
+ parsed = _email_mod.message_from_string(msg["raw"], policy=policy.default)
+ # 优先取纯文本
+ body = parsed.get_body(preferencelist=('plain', 'html'))
+ if body:
+ content = body.get_content() or ""
+ if not content:
+ # 回退:遍历所有 part
+ for part in parsed.walk():
+ ctype = part.get_content_type()
+ if ctype in ("text/plain", "text/html"):
+ payload = part.get_content()
+ if payload:
+ content = str(payload)
+ break
+ except Exception as parse_err:
+ print(f"[CFMail] MIME解析失败,回退raw: {parse_err}", flush=True)
+ content = msg.get("raw", "")
+ print(f"[CFMail] 邮件id={msg_id}, 内容前200={content[:200]}", flush=True)
+ code = _extract_code(content)
+ if code:
+ print(f"[CFMail] 成功提取验证码: {code}", flush=True)
+ return code
+ except Exception as e:
+ print(f"[CFMail] 轮询异常: {e}", flush=True)
+ time.sleep(3)
+ print("[CFMail] wait_for_otp 超时, 未获取到验证码", flush=True)
+ return ""
+
+
+# ==================== 多提供商路由 ====================
+
+
+class MultiMailRouter:
+ """线程安全的多邮箱提供商路由器,支持轮询/随机/容错策略"""
+
+ def __init__(self, config: Dict[str, Any]):
+ providers_list: List[str] = config.get("mail_providers") or []
+ provider_configs: Dict[str, Dict] = config.get("mail_provider_configs") or {}
+ self.strategy: str = config.get("mail_strategy", "round_robin")
+
+ if not providers_list:
+ legacy = config.get("mail_provider", "mailtm")
+ providers_list = [legacy]
+ provider_configs = {legacy: config.get("mail_config") or {}}
+
+ self._provider_names: List[str] = []
+ self._providers: Dict[str, MailProvider] = {}
+ self._failures: Dict[str, int] = {}
+ self._lock = threading.RLock()
+ self._counter = itertools.count()
+
+ for name in providers_list:
+ try:
+ p = create_provider_by_name(name, provider_configs.get(name, {}))
+ self._provider_names.append(name)
+ self._providers[name] = p
+ self._failures[name] = 0
+ except Exception as e:
+ logger.warning("创建邮箱提供商 %s 失败: %s", name, e)
+
+ if not self._providers:
+ if providers_list:
+ raise RuntimeError(f"邮箱提供商配置无效: {', '.join(str(n) for n in providers_list)}")
+ fallback = create_provider_by_name("mailtm", {})
+ self._provider_names = ["mailtm"]
+ self._providers = {"mailtm": fallback}
+ self._failures = {"mailtm": 0}
+
+ def next_provider(self) -> Tuple[str, MailProvider]:
+ with self._lock:
+ names = self._provider_names
+ if not names:
+ raise RuntimeError("无可用邮箱提供商")
+
+ if self.strategy == "random":
+ name = random.choice(names)
+ elif self.strategy == "failover":
+ name = min(names, key=lambda n: self._failures.get(n, 0))
+ else:
+ idx = next(self._counter) % len(names)
+ name = names[idx]
+ return name, self._providers[name]
+
+ def providers(self) -> List[Tuple[str, MailProvider]]:
+ with self._lock:
+ return [(n, self._providers[n]) for n in self._provider_names]
+
+ def report_success(self, provider_name: str) -> None:
+ with self._lock:
+ self._failures[provider_name] = max(0, self._failures.get(provider_name, 0) - 1)
+
+ def report_failure(self, provider_name: str) -> None:
+ with self._lock:
+ self._failures[provider_name] = self._failures.get(provider_name, 0) + 1
+
+
+# ==================== 工厂函数 ====================
+
+
+def create_provider_by_name(provider_type: str, mail_cfg: Dict[str, Any]) -> MailProvider:
+ """根据提供商名称和单独配置创建实例"""
+ provider_type = provider_type.lower().strip()
+ api_base = str(mail_cfg.get("api_base", "")).strip()
+
+ if provider_type == "moemail":
+ return MoeMailProvider(
+ api_base=api_base or "https://your-moemail-api.example.com",
+ api_key=str(mail_cfg.get("api_key", "")).strip(),
+ )
+ elif provider_type == "duckmail":
+ return DuckMailProvider(
+ api_base=api_base or "https://api.duckmail.sbs",
+ bearer_token=str(mail_cfg.get("bearer_token", "")).strip(),
+ )
+ elif provider_type == "cloudflare_temp_email":
+ return CloudflareTempEmailProvider(
+ api_base=api_base,
+ admin_password=str(mail_cfg.get("admin_password", "")).strip(),
+ domain=str(mail_cfg.get("domain", "")).strip(),
+ )
+ elif provider_type == "mailtm":
+ return MailTmProvider(api_base=api_base or "https://api.mail.tm")
+ raise ValueError(f"未知邮箱提供商: {provider_type}")
+
+
+def create_provider(config: Dict[str, Any]) -> MailProvider:
+ """兼容旧配置格式的工厂函数"""
+ provider_type = str(config.get("mail_provider", "mailtm")).lower()
+ mail_cfg = config.get("mail_config") or {}
+ return create_provider_by_name(provider_type, mail_cfg)
diff --git a/openai_pool_orchestrator/pool_maintainer.py b/openai_pool_orchestrator/pool_maintainer.py
new file mode 100755
index 0000000..6bd5a6d
--- /dev/null
+++ b/openai_pool_orchestrator/pool_maintainer.py
@@ -0,0 +1,1061 @@
+"""
+账号池维护模块
+支持 CPA 平台和 Sub2Api 平台的探测、清理、计数和补号
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import re
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+from urllib.parse import quote
+
+import requests as _requests
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+try:
+ import aiohttp
+except ImportError:
+ aiohttp = None
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_MGMT_UA = "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal"
+
+
+def _mgmt_headers(token: str) -> Dict[str, str]:
+ return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
+
+
+def _build_session(proxy: str = "") -> _requests.Session:
+ s = _requests.Session()
+ retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
+ adapter = HTTPAdapter(max_retries=retry)
+ s.mount("https://", adapter)
+ s.mount("http://", adapter)
+ if proxy:
+ s.proxies = {"http": proxy, "https": proxy}
+ return s
+
+
+def _get_item_type(item: Dict[str, Any]) -> str:
+ return str(item.get("type") or item.get("typo") or "")
+
+
+def _safe_json(text: str) -> Dict[str, Any]:
+ try:
+ return json.loads(text)
+ except Exception:
+ return {}
+
+
+def _extract_account_id(item: Dict[str, Any]) -> Optional[str]:
+ for key in ("chatgpt_account_id", "chatgptAccountId", "account_id", "accountId"):
+ val = item.get(key)
+ if val:
+ return str(val)
+ return None
+
+
+def _parse_time_to_epoch(raw: Any) -> float:
+ text = str(raw or "").strip()
+ if not text:
+ return 0.0
+ iso_text = text[:-1] + "+00:00" if text.endswith("Z") else text
+ try:
+ return datetime.fromisoformat(iso_text).timestamp()
+ except Exception:
+ pass
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
+ try:
+ return datetime.strptime(text, fmt).timestamp()
+ except Exception:
+ continue
+ return 0.0
+
+
+class PoolMaintainer:
+ def __init__(
+ self,
+ cpa_base_url: str,
+ cpa_token: str,
+ target_type: str = "codex",
+ min_candidates: int = 800,
+ used_percent_threshold: int = 95,
+ user_agent: str = DEFAULT_MGMT_UA,
+ ):
+ self.base_url = cpa_base_url.rstrip("/")
+ self.token = cpa_token
+ self.target_type = target_type
+ self.min_candidates = min_candidates
+ self.used_percent_threshold = used_percent_threshold
+ self.user_agent = user_agent
+
+ def fetch_auth_files(self, timeout: int = 15) -> List[Dict[str, Any]]:
+ resp = _requests.get(
+ f"{self.base_url}/v0/management/auth-files",
+ headers=_mgmt_headers(self.token),
+ timeout=timeout,
+ )
+ resp.raise_for_status()
+ raw = resp.json()
+ data = raw if isinstance(raw, dict) else {}
+ files = data.get("files", [])
+ return files if isinstance(files, list) else []
+
+ def get_pool_status(self, timeout: int = 15) -> Dict[str, Any]:
+ try:
+ files = self.fetch_auth_files(timeout)
+ candidates = [f for f in files if _get_item_type(f).lower() == self.target_type.lower()]
+ total = len(files)
+ cand_count = len(candidates)
+ return {
+ "total": total,
+ "candidates": cand_count,
+ "error_count": max(0, total - cand_count),
+ "threshold": self.min_candidates,
+ "healthy": cand_count >= self.min_candidates,
+ "percent": round(cand_count / self.min_candidates * 100, 1) if self.min_candidates > 0 else 100,
+ "last_checked": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "error": None,
+ }
+ except Exception as e:
+ return {
+ "total": 0,
+ "candidates": 0,
+ "error_count": 0,
+ "threshold": self.min_candidates,
+ "healthy": False,
+ "percent": 0,
+ "last_checked": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "error": str(e),
+ }
+
+ def test_connection(self, timeout: int = 10) -> Dict[str, Any]:
+ try:
+ files = self.fetch_auth_files(timeout)
+ candidates = [f for f in files if _get_item_type(f).lower() == self.target_type.lower()]
+ return {
+ "ok": True,
+ "total": len(files),
+ "candidates": len(candidates),
+ "message": f"连接成功,共 {len(files)} 个账号,{len(candidates)} 个 {self.target_type} 账号",
+ }
+ except Exception as e:
+ return {"ok": False, "total": 0, "candidates": 0, "message": f"连接失败: {e}"}
+
+ async def probe_accounts_async(
+ self, workers: int = 20, timeout: int = 10, retries: int = 1,
+ ) -> Dict[str, Any]:
+ if aiohttp is None:
+ raise RuntimeError("需要安装 aiohttp: pip install aiohttp")
+
+ files = self.fetch_auth_files(timeout)
+ candidates = [f for f in files if _get_item_type(f).lower() == self.target_type.lower()]
+
+ if not candidates:
+ return {"total": len(files), "candidates": 0, "invalid": [], "files": files}
+
+ semaphore = asyncio.Semaphore(max(1, workers))
+ connector = aiohttp.TCPConnector(limit=max(1, workers))
+ client_timeout = aiohttp.ClientTimeout(total=max(1, timeout))
+
+ async def probe_one(session: aiohttp.ClientSession, item: Dict[str, Any]) -> Dict[str, Any]:
+ auth_index = item.get("auth_index")
+ name = item.get("name") or item.get("id")
+ result = {
+ "name": name,
+ "auth_index": auth_index,
+ "invalid_401": False,
+ "invalid_used_percent": False,
+ "used_percent": None,
+ "error": None,
+ }
+ if not auth_index:
+ result["error"] = "missing auth_index"
+ return result
+
+ account_id = _extract_account_id(item)
+ call_header = {
+ "Authorization": "Bearer $TOKEN$",
+ "Content-Type": "application/json",
+ "User-Agent": self.user_agent,
+ }
+ if account_id:
+ call_header["Chatgpt-Account-Id"] = account_id
+
+ payload = {
+ "authIndex": auth_index,
+ "method": "GET",
+ "url": "https://chatgpt.com/backend-api/wham/usage",
+ "header": call_header,
+ }
+
+ for attempt in range(retries + 1):
+ try:
+ async with semaphore:
+ async with session.post(
+ f"{self.base_url}/v0/management/api-call",
+ headers={**_mgmt_headers(self.token), "Content-Type": "application/json"},
+ json=payload,
+ timeout=timeout,
+ ) as resp:
+ text = await resp.text()
+ if resp.status >= 400:
+ raise RuntimeError(f"HTTP {resp.status}: {text[:200]}")
+ data = _safe_json(text)
+ sc = data.get("status_code")
+ result["invalid_401"] = sc == 401
+ if sc == 200:
+ try:
+ body_data = _safe_json(data.get("body", ""))
+ used_pct = (body_data.get("rate_limit", {}).get("primary_window", {}).get("used_percent"))
+ if used_pct is not None:
+ result["used_percent"] = used_pct
+ result["invalid_used_percent"] = used_pct >= self.used_percent_threshold
+ except Exception:
+ pass
+ return result
+ except Exception as e:
+ result["error"] = str(e)
+ if attempt >= retries:
+ return result
+ return result
+
+ async def delete_one(session: aiohttp.ClientSession, name: str) -> Dict[str, Any]:
+ encoded = quote(name, safe="")
+ try:
+ async with semaphore:
+ async with session.delete(
+ f"{self.base_url}/v0/management/auth-files?name={encoded}",
+ headers=_mgmt_headers(self.token),
+ timeout=timeout,
+ ) as resp:
+ text = await resp.text()
+ data = _safe_json(text)
+ ok = resp.status == 200 and data.get("status") == "ok"
+ return {"name": name, "deleted": ok}
+ except Exception:
+ return {"name": name, "deleted": False}
+
+ invalid_list = []
+ async with aiohttp.ClientSession(connector=connector, timeout=client_timeout, trust_env=True) as session:
+ tasks = [asyncio.create_task(probe_one(session, item)) for item in candidates]
+ for task in asyncio.as_completed(tasks):
+ result = await task
+ if result.get("invalid_401") or result.get("invalid_used_percent"):
+ invalid_list.append(result)
+
+ return {
+ "total": len(files),
+ "candidates": len(candidates),
+ "invalid": invalid_list,
+ "files": files,
+ }
+
+ async def clean_invalid_async(self, workers: int = 20, timeout: int = 10, retries: int = 1) -> Dict[str, Any]:
+ if aiohttp is None:
+ raise RuntimeError("需要安装 aiohttp: pip install aiohttp")
+
+ probe_result = await self.probe_accounts_async(workers, timeout, retries)
+ invalid = probe_result["invalid"]
+ names = [str(r["name"]) for r in invalid if r.get("name")]
+
+ deleted_ok = 0
+ deleted_fail = 0
+
+ if names:
+ semaphore = asyncio.Semaphore(max(1, workers))
+ connector = aiohttp.TCPConnector(limit=max(1, workers))
+ client_timeout = aiohttp.ClientTimeout(total=max(1, timeout))
+
+ async with aiohttp.ClientSession(connector=connector, timeout=client_timeout, trust_env=True) as session:
+ async def do_delete(name: str) -> bool:
+ encoded = quote(name, safe="")
+ try:
+ async with semaphore:
+ async with session.delete(
+ f"{self.base_url}/v0/management/auth-files?name={encoded}",
+ headers=_mgmt_headers(self.token),
+ timeout=timeout,
+ ) as resp:
+ text = await resp.text()
+ data = _safe_json(text)
+ return resp.status == 200 and data.get("status") == "ok"
+ except Exception:
+ return False
+
+ tasks = [asyncio.create_task(do_delete(n)) for n in names]
+ for task in asyncio.as_completed(tasks):
+ if await task:
+ deleted_ok += 1
+ else:
+ deleted_fail += 1
+
+ return {
+ "total": probe_result["total"],
+ "candidates": probe_result["candidates"],
+ "invalid_count": len(invalid),
+ "deleted_ok": deleted_ok,
+ "deleted_fail": deleted_fail,
+ }
+
+ def probe_and_clean_sync(self, workers: int = 20, timeout: int = 10, retries: int = 1) -> Dict[str, Any]:
+ return asyncio.run(self.clean_invalid_async(workers, timeout, retries))
+
+ def calculate_gap(self, current_candidates: Optional[int] = None) -> int:
+ if current_candidates is None:
+ status = self.get_pool_status()
+ if status.get("error"):
+ raise RuntimeError(f"CPA 池状态查询失败: {status['error']}")
+ current_candidates = status["candidates"]
+ gap = self.min_candidates - current_candidates
+ return max(0, gap)
+
+ def upload_token(self, filename: str, token_data: Dict[str, Any], proxy: str = "") -> bool:
+ if not self.base_url or not self.token:
+ return False
+ content = json.dumps(token_data, ensure_ascii=False).encode("utf-8")
+ files = {"file": (filename, content, "application/json")}
+ headers = {"Authorization": f"Bearer {self.token}"}
+
+ with _build_session(proxy) as session:
+ for attempt in range(3):
+ try:
+ resp = session.post(
+ f"{self.base_url}/v0/management/auth-files",
+ files=files, headers=headers, verify=False, timeout=30,
+ )
+ if resp.status_code in (200, 201, 204):
+ return True
+ except Exception:
+ pass
+ if attempt < 2:
+ time.sleep(2 ** attempt)
+ return False
+
+
+class Sub2ApiMaintainer:
+ """Sub2Api 平台池维护 — 通过 Admin API 管理账号池"""
+
+ def __init__(
+ self,
+ base_url: str,
+ bearer_token: str,
+ min_candidates: int = 200,
+ email: str = "",
+ password: str = "",
+ ):
+ self.base_url = base_url.rstrip("/")
+ self.bearer_token = bearer_token
+ self.min_candidates = min_candidates
+ self.email = email
+ self.password = password
+ self._auth_lock = threading.Lock()
+
+ def _headers(self) -> Dict[str, str]:
+ return {
+ "Authorization": f"Bearer {self.bearer_token}",
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ }
+
+ def _login(self) -> str:
+ with _build_session() as session:
+ resp = session.post(
+ f"{self.base_url}/api/v1/auth/login",
+ json={"email": self.email, "password": self.password},
+ timeout=15,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ token = (
+ data.get("token")
+ or data.get("access_token")
+ or (data.get("data") or {}).get("token")
+ or (data.get("data") or {}).get("access_token")
+ or ""
+ )
+ if token:
+ self.bearer_token = token
+ return token
+
+ def _request(self, method: str, path: str, **kwargs) -> _requests.Response:
+ kwargs.setdefault("timeout", 15)
+ url = f"{self.base_url}{path}"
+ with _build_session() as session:
+ resp = session.request(method, url, headers=self._headers(), **kwargs)
+ if resp.status_code == 401 and self.email and self.password:
+ current_token = self.bearer_token
+ with self._auth_lock:
+ if self.bearer_token == current_token:
+ self._login()
+ refreshed_token = self.bearer_token
+ if refreshed_token or self.bearer_token != current_token:
+ resp = session.request(method, url, headers=self._headers(), **kwargs)
+ return resp
+ resp = session.request(method, url, headers=self._headers(), **kwargs)
+ return resp
+
+ def get_dashboard_stats(self, timeout: int = 15) -> Dict[str, Any]:
+ resp = self._request(
+ "GET", "/api/v1/admin/dashboard/stats",
+ params={"timezone": "Asia/Shanghai"}, timeout=timeout,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ return data.get("data") if isinstance(data.get("data"), dict) else data
+
+ def list_accounts(
+ self, page: int = 1, page_size: int = 100, timeout: int = 15,
+ ) -> Dict[str, Any]:
+ params = {
+ "page": page, "page_size": page_size,
+ "platform": "openai", "type": "oauth",
+ }
+ resp = self._request(
+ "GET", "/api/v1/admin/accounts",
+ params=params, timeout=timeout,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ return data.get("data") if isinstance(data.get("data"), dict) else data
+
+ def _list_all_accounts(self, timeout: int = 15, page_size: int = 100) -> List[Dict[str, Any]]:
+ all_accounts: List[Dict[str, Any]] = []
+ page = 1
+ while True:
+ data = self.list_accounts(page=page, page_size=page_size, timeout=timeout)
+ items = data.get("items") or []
+ if not isinstance(items, list):
+ items = []
+ all_accounts.extend([i for i in items if isinstance(i, dict)])
+ if not items or len(items) < page_size:
+ break
+ total = data.get("total")
+ if isinstance(total, int) and total > 0 and len(all_accounts) >= total:
+ break
+ page += 1
+ return all_accounts
+
+ def _account_identity(self, item: Dict[str, Any]) -> Dict[str, str]:
+ email = ""
+ rt = ""
+ extra = item.get("extra")
+ if isinstance(extra, dict):
+ email = str(extra.get("email") or "").strip().lower()
+ if not email:
+ name = str(item.get("name") or "").strip().lower()
+ if "@" in name:
+ email = name
+ creds = item.get("credentials")
+ if isinstance(creds, dict):
+ rt = str(creds.get("refresh_token") or "").strip()
+ return {"email": email, "refresh_token": rt}
+
+ @staticmethod
+ def _account_sort_key(item: Dict[str, Any]) -> tuple[float, int]:
+ updated = _parse_time_to_epoch(item.get("updated_at") or item.get("updatedAt"))
+ try:
+ item_id = int(item.get("id") or 0)
+ except (TypeError, ValueError):
+ item_id = 0
+ return (updated, item_id)
+
+ @staticmethod
+ def _normalize_account_id(raw: Any) -> Optional[int]:
+ try:
+ account_id = int(raw)
+ except (TypeError, ValueError):
+ return None
+ if account_id <= 0:
+ return None
+ return account_id
+
+ @staticmethod
+ def _is_abnormal_status(status: Any) -> bool:
+ return str(status or "").strip().lower() in ("error", "disabled")
+
+ def _build_dedupe_plan(self, all_accounts: List[Dict[str, Any]], details_limit: int = 120) -> Dict[str, Any]:
+ id_to_account: Dict[int, Dict[str, Any]] = {}
+ parent: Dict[int, int] = {}
+ key_to_ids: Dict[str, List[int]] = {}
+
+ for item in all_accounts:
+ acc_id = self._normalize_account_id(item.get("id"))
+ if acc_id is None:
+ continue
+ id_to_account[acc_id] = item
+ parent[acc_id] = acc_id
+
+ identity = self._account_identity(item)
+ email = identity["email"]
+ refresh_token = identity["refresh_token"]
+ if email:
+ key_to_ids.setdefault(f"email:{email}", []).append(acc_id)
+ if refresh_token:
+ key_to_ids.setdefault(f"rt:{refresh_token}", []).append(acc_id)
+
+ def find(x: int) -> int:
+ root = x
+ while parent[root] != root:
+ root = parent[root]
+ while parent[x] != x:
+ nxt = parent[x]
+ parent[x] = root
+ x = nxt
+ return root
+
+ def union(a: int, b: int) -> None:
+ ra = find(a)
+ rb = find(b)
+ if ra != rb:
+ parent[rb] = ra
+
+ for ids in key_to_ids.values():
+ if len(ids) > 1:
+ head = ids[0]
+ for acc_id in ids[1:]:
+ union(head, acc_id)
+
+ components: Dict[int, List[int]] = {}
+ for acc_id in id_to_account.keys():
+ root = find(acc_id)
+ components.setdefault(root, []).append(acc_id)
+
+ duplicate_groups = [ids for ids in components.values() if len(ids) > 1]
+ delete_ids: List[int] = []
+ group_details: List[Dict[str, Any]] = []
+
+ for group_ids in duplicate_groups:
+ group_items = [id_to_account[i] for i in group_ids]
+ keep_item = max(group_items, key=self._account_sort_key)
+ keep_id = self._normalize_account_id(keep_item.get("id")) or 0
+ group_delete_ids = sorted([i for i in group_ids if i != keep_id], reverse=True)
+ delete_ids.extend(group_delete_ids)
+
+ if len(group_details) < details_limit:
+ emails_set = set()
+ for it in group_items:
+ identity = self._account_identity(it)
+ if identity["email"]:
+ emails_set.add(identity["email"])
+ emails = sorted(emails_set)
+ group_details.append({
+ "keep_id": keep_id,
+ "delete_ids": group_delete_ids,
+ "size": len(group_ids),
+ "emails": emails,
+ })
+
+ return {
+ "duplicate_groups": len(duplicate_groups),
+ "duplicate_accounts": sum(len(g) for g in duplicate_groups),
+ "delete_ids": delete_ids,
+ "groups_preview": group_details,
+ "truncated_groups": max(0, len(duplicate_groups) - len(group_details)),
+ }
+
+ def list_account_inventory(self, timeout: int = 15) -> Dict[str, Any]:
+ all_accounts = self._list_all_accounts(timeout=timeout, page_size=100)
+ dedupe_plan = self._build_dedupe_plan(
+ all_accounts,
+ details_limit=max(1, len(all_accounts)),
+ )
+ duplicate_delete_ids = {
+ int(account_id)
+ for account_id in (dedupe_plan.get("delete_ids") or [])
+ if isinstance(account_id, int)
+ }
+ duplicate_map: Dict[int, Dict[str, Any]] = {}
+ for group in dedupe_plan.get("groups_preview") or []:
+ keep_id = self._normalize_account_id(group.get("keep_id"))
+ delete_ids = [
+ account_id
+ for account_id in (
+ self._normalize_account_id(item)
+ for item in (group.get("delete_ids") or [])
+ )
+ if account_id is not None
+ ]
+ group_ids = ([keep_id] if keep_id is not None else []) + delete_ids
+ group_size = max(1, int(group.get("size") or len(group_ids) or 1))
+ emails = [str(email).strip().lower() for email in (group.get("emails") or []) if str(email).strip()]
+ for account_id in group_ids:
+ duplicate_map[account_id] = {
+ "group_size": group_size,
+ "keep_id": keep_id,
+ "delete_candidate": account_id in duplicate_delete_ids,
+ "emails": emails,
+ }
+
+ items: List[Dict[str, Any]] = []
+ abnormal_count = 0
+ for raw_item in sorted(all_accounts, key=self._account_sort_key, reverse=True):
+ account_id = self._normalize_account_id(raw_item.get("id"))
+ if account_id is None:
+ continue
+ identity = self._account_identity(raw_item)
+ status = str(raw_item.get("status") or "").strip().lower() or "unknown"
+ if self._is_abnormal_status(status):
+ abnormal_count += 1
+ duplicate_info = duplicate_map.get(account_id) or {}
+ items.append({
+ "id": account_id,
+ "name": str(raw_item.get("name") or "").strip(),
+ "email": identity.get("email") or str(raw_item.get("name") or "").strip(),
+ "status": status,
+ "updated_at": raw_item.get("updated_at") or raw_item.get("updatedAt") or "",
+ "created_at": raw_item.get("created_at") or raw_item.get("createdAt") or "",
+ "is_duplicate": bool(duplicate_info),
+ "duplicate_group_size": int(duplicate_info.get("group_size") or 0),
+ "duplicate_keep": duplicate_info.get("keep_id") == account_id,
+ "duplicate_delete_candidate": bool(duplicate_info.get("delete_candidate")),
+ "duplicate_emails": duplicate_info.get("emails") or [],
+ })
+
+ return {
+ "total": len(items),
+ "error_count": abnormal_count,
+ "duplicate_groups": int(dedupe_plan.get("duplicate_groups", 0)),
+ "duplicate_accounts": int(dedupe_plan.get("duplicate_accounts", 0)),
+ "items": items,
+ }
+
+ def _refresh_accounts_parallel(self, account_ids: List[int], timeout: int = 30, workers: int = 8) -> Dict[str, List[int]]:
+ success_ids: List[int] = []
+ failed_ids: List[int] = []
+ ids = [i for i in account_ids if isinstance(i, int) and i > 0]
+ if not ids:
+ return {"success_ids": success_ids, "failed_ids": failed_ids}
+
+ pool_workers = max(1, min(workers, 16, len(ids)))
+ with ThreadPoolExecutor(max_workers=pool_workers) as executor:
+ future_to_id = {
+ executor.submit(self.refresh_account, account_id, timeout=timeout): account_id
+ for account_id in ids
+ }
+ for future in as_completed(future_to_id):
+ account_id = future_to_id[future]
+ try:
+ ok = bool(future.result())
+ except Exception:
+ ok = False
+ if ok:
+ success_ids.append(account_id)
+ else:
+ failed_ids.append(account_id)
+ return {"success_ids": success_ids, "failed_ids": failed_ids}
+
+ def _delete_accounts_parallel(self, account_ids: List[int], timeout: int = 15, workers: int = 12) -> Dict[str, Any]:
+ deleted_ok_ids: List[int] = []
+ failed_ids: List[int] = []
+ unique_ids = sorted({i for i in account_ids if isinstance(i, int) and i > 0}, reverse=True)
+ if not unique_ids:
+ return {"deleted_ok": 0, "deleted_fail": 0, "deleted_ok_ids": deleted_ok_ids, "failed_ids": failed_ids}
+
+ pool_workers = max(1, min(workers, 24, len(unique_ids)))
+ with ThreadPoolExecutor(max_workers=pool_workers) as executor:
+ future_to_id = {
+ executor.submit(self.delete_account, account_id, timeout=timeout): account_id
+ for account_id in unique_ids
+ }
+ for future in as_completed(future_to_id):
+ account_id = future_to_id[future]
+ try:
+ ok = bool(future.result())
+ except Exception:
+ ok = False
+ if ok:
+ deleted_ok_ids.append(account_id)
+ else:
+ failed_ids.append(account_id)
+
+ return {
+ "deleted_ok": len(deleted_ok_ids),
+ "deleted_fail": len(failed_ids),
+ "deleted_ok_ids": deleted_ok_ids,
+ "failed_ids": failed_ids,
+ }
+
+ def dedupe_duplicate_accounts(self, timeout: int = 15, dry_run: bool = True, details_limit: int = 120) -> Dict[str, Any]:
+ """
+ 清理 Sub2Api 中 OpenAI OAuth 重复账号(按 email 或 refresh_token 判重)。
+ - 同一连通重复组保留“最新”账号(updated_at 优先,其次 id 最大)。
+ - dry_run=True 时仅预览,不执行删除。
+ """
+ all_accounts = self._list_all_accounts(timeout=timeout, page_size=100)
+ dedupe_plan = self._build_dedupe_plan(all_accounts, details_limit=details_limit)
+ delete_ids = dedupe_plan["delete_ids"]
+ deleted_ok = 0
+ deleted_fail = 0
+ failed_ids: List[int] = []
+ if not dry_run and delete_ids:
+ delete_result = self._delete_accounts_parallel(delete_ids, timeout=timeout, workers=12)
+ deleted_ok = int(delete_result.get("deleted_ok", 0))
+ deleted_fail = int(delete_result.get("deleted_fail", 0))
+ failed_ids = list(delete_result.get("failed_ids") or [])
+
+ return {
+ "dry_run": dry_run,
+ "total": len(all_accounts),
+ "duplicate_groups": int(dedupe_plan["duplicate_groups"]),
+ "duplicate_accounts": int(dedupe_plan["duplicate_accounts"]),
+ "to_delete": len(delete_ids),
+ "deleted_ok": deleted_ok,
+ "deleted_fail": deleted_fail,
+ "failed_delete_ids": failed_ids[:200],
+ "groups_preview": dedupe_plan["groups_preview"],
+ "truncated_groups": int(dedupe_plan["truncated_groups"]),
+ }
+
+ def probe_accounts(self, account_ids: List[int], timeout: int = 30) -> Dict[str, Any]:
+ ids = sorted({
+ account_id
+ for account_id in (
+ self._normalize_account_id(item)
+ for item in (account_ids or [])
+ )
+ if account_id is not None
+ })
+ if not ids:
+ return {
+ "requested": 0,
+ "refreshed_ok": 0,
+ "refreshed_fail": 0,
+ "recovered": 0,
+ "still_abnormal": 0,
+ "details": [],
+ }
+
+ before_status = self._list_accounts_by_ids(ids, timeout=timeout)
+ refresh_result = self._refresh_accounts_parallel(ids, timeout=max(30, timeout), workers=8)
+ success_ids = set(refresh_result.get("success_ids") or [])
+ failed_ids = set(refresh_result.get("failed_ids") or [])
+
+ if success_ids:
+ time.sleep(2)
+ after_status = self._list_accounts_by_ids(ids, timeout=timeout)
+
+ recovered_ids: List[int] = []
+ abnormal_after_ids: List[int] = []
+ details: List[Dict[str, Any]] = []
+ for account_id in ids:
+ before = str(before_status.get(account_id) or "unknown").strip().lower()
+ after = str(after_status.get(account_id) or before or "unknown").strip().lower()
+ if self._is_abnormal_status(before) and not self._is_abnormal_status(after):
+ recovered_ids.append(account_id)
+ if self._is_abnormal_status(after):
+ abnormal_after_ids.append(account_id)
+ if len(details) < 200:
+ details.append({
+ "id": account_id,
+ "before_status": before,
+ "after_status": after,
+ "refresh_ok": account_id in success_ids,
+ })
+
+ return {
+ "requested": len(ids),
+ "refreshed_ok": len(success_ids),
+ "refreshed_fail": len(failed_ids),
+ "recovered": len(recovered_ids),
+ "still_abnormal": len(abnormal_after_ids),
+ "details": details,
+ }
+
+ def delete_accounts_batch(self, account_ids: List[int], timeout: int = 15) -> Dict[str, Any]:
+ ids = [
+ account_id
+ for account_id in (
+ self._normalize_account_id(item)
+ for item in (account_ids or [])
+ )
+ if account_id is not None
+ ]
+ delete_result = self._delete_accounts_parallel(ids, timeout=timeout, workers=12)
+ return {
+ "requested": len({*ids}),
+ "deleted_ok": int(delete_result.get("deleted_ok", 0)),
+ "deleted_fail": int(delete_result.get("deleted_fail", 0)),
+ "deleted_ok_ids": list(delete_result.get("deleted_ok_ids") or []),
+ "failed_ids": list(delete_result.get("failed_ids") or []),
+ }
+
+ def handle_exception_accounts(
+ self,
+ account_ids: Optional[List[int]] = None,
+ timeout: int = 30,
+ delete_unresolved: bool = True,
+ ) -> Dict[str, Any]:
+ requested_ids = [
+ account_id
+ for account_id in (
+ self._normalize_account_id(item)
+ for item in (account_ids or [])
+ )
+ if account_id is not None
+ ]
+
+ if requested_ids:
+ current_status = self._list_accounts_by_ids(requested_ids, timeout=timeout)
+ target_ids = [
+ account_id
+ for account_id in requested_ids
+ if self._is_abnormal_status(current_status.get(account_id))
+ ]
+ skipped_non_abnormal = max(0, len(set(requested_ids)) - len(target_ids))
+ else:
+ all_accounts = self._list_all_accounts(timeout=timeout, page_size=100)
+ target_ids = [
+ account_id
+ for account_id in (
+ self._normalize_account_id(item.get("id"))
+ for item in all_accounts
+ if self._is_abnormal_status(item.get("status"))
+ )
+ if account_id is not None
+ ]
+ skipped_non_abnormal = 0
+
+ unique_target_ids = sorted(set(target_ids))
+ if not unique_target_ids:
+ return {
+ "requested": len(set(requested_ids)) if requested_ids else 0,
+ "targeted": 0,
+ "refreshed_ok": 0,
+ "refreshed_fail": 0,
+ "recovered": 0,
+ "remaining_abnormal": 0,
+ "deleted_ok": 0,
+ "deleted_fail": 0,
+ "skipped_non_abnormal": skipped_non_abnormal,
+ }
+
+ refresh_result = self._refresh_accounts_parallel(unique_target_ids, timeout=max(30, timeout), workers=8)
+ if refresh_result.get("success_ids"):
+ time.sleep(2)
+ after_status = self._list_accounts_by_ids(unique_target_ids, timeout=timeout)
+ remaining_abnormal_ids = [
+ account_id
+ for account_id in unique_target_ids
+ if self._is_abnormal_status(after_status.get(account_id))
+ ]
+ remaining_abnormal_set = set(remaining_abnormal_ids)
+ recovered_ids = [
+ account_id
+ for account_id in unique_target_ids
+ if account_id not in remaining_abnormal_set
+ ]
+
+ delete_result = {
+ "deleted_ok": 0,
+ "deleted_fail": 0,
+ "deleted_ok_ids": [],
+ "failed_ids": [],
+ }
+ if delete_unresolved and remaining_abnormal_ids:
+ delete_result = self._delete_accounts_parallel(remaining_abnormal_ids, timeout=timeout, workers=12)
+
+ return {
+ "requested": len(set(requested_ids)) if requested_ids else len(unique_target_ids),
+ "targeted": len(unique_target_ids),
+ "refreshed_ok": len(refresh_result.get("success_ids") or []),
+ "refreshed_fail": len(refresh_result.get("failed_ids") or []),
+ "recovered": len(recovered_ids),
+ "remaining_abnormal": len(remaining_abnormal_ids),
+ "deleted_ok": int(delete_result.get("deleted_ok", 0)),
+ "deleted_fail": int(delete_result.get("deleted_fail", 0)),
+ "deleted_ok_ids": list(delete_result.get("deleted_ok_ids") or []),
+ "failed_ids": list(delete_result.get("failed_ids") or []),
+ "skipped_non_abnormal": skipped_non_abnormal,
+ }
+
+ def refresh_account(self, account_id: int, timeout: int = 30) -> bool:
+ try:
+ resp = self._request(
+ "POST", f"/api/v1/admin/accounts/{account_id}/refresh",
+ timeout=timeout,
+ )
+ return resp.status_code in (200, 201)
+ except Exception:
+ return False
+
+ def delete_account(self, account_id: int, timeout: int = 15) -> bool:
+ try:
+ resp = self._request(
+ "DELETE", f"/api/v1/admin/accounts/{account_id}",
+ timeout=timeout,
+ )
+ return resp.status_code in (200, 204)
+ except Exception:
+ return False
+
+ def get_pool_status(self, timeout: int = 15) -> Dict[str, Any]:
+ try:
+ all_accounts = self._list_all_accounts(timeout=timeout, page_size=100)
+ error = sum(
+ 1 for account in all_accounts
+ if self._is_abnormal_status(account.get("status"))
+ )
+ total = len(all_accounts)
+ normal = max(0, total - error)
+ return {
+ "total": total,
+ "candidates": normal,
+ "error_count": error,
+ "threshold": self.min_candidates,
+ "healthy": normal >= self.min_candidates,
+ "percent": round(normal / self.min_candidates * 100, 1) if self.min_candidates > 0 else 100,
+ "last_checked": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "error": None,
+ }
+ except Exception as e:
+ return {
+ "total": 0, "candidates": 0, "error_count": 0,
+ "threshold": self.min_candidates, "healthy": False,
+ "percent": 0, "last_checked": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "error": str(e),
+ }
+
+ def test_connection(self, timeout: int = 10) -> Dict[str, Any]:
+ try:
+ status = self.get_pool_status(timeout)
+ total = int(status.get("total", 0))
+ normal = int(status.get("candidates", 0))
+ error = int(status.get("error_count", 0))
+ return {
+ "ok": True,
+ "total": total,
+ "normal": normal,
+ "error": error,
+ "message": f"连接成功,共 {total} 个账号,{normal} 正常,{error} 异常",
+ }
+ except Exception as e:
+ return {"ok": False, "total": 0, "normal": 0, "error": 0,
+ "message": f"连接失败: {e}"}
+
+ def _list_accounts_by_ids(
+ self, ids: List[int], timeout: int = 15,
+ ) -> Dict[int, str]:
+ """查询指定 ID 的账号当前状态,返回 {id: status}"""
+ result: Dict[int, str] = {}
+ id_set = set(ids)
+ page = 1
+ while id_set:
+ data = self.list_accounts(page=page, page_size=100, timeout=timeout)
+ items = data.get("items") or []
+ if not items:
+ break
+ for item in items:
+ aid = item.get("id")
+ if aid in id_set:
+ result[aid] = str(item.get("status", ""))
+ id_set.discard(aid)
+ total = data.get("total", 0)
+ if page * 100 >= total or len(items) < 100:
+ break
+ page += 1
+ return result
+
+ def probe_and_clean_sync(self, timeout: int = 15, actions: Optional[Dict[str, bool]] = None) -> Dict[str, Any]:
+ action_flags = {
+ "refresh_abnormal_accounts": bool((actions or {}).get("refresh_abnormal_accounts", True)),
+ "delete_abnormal_accounts": bool((actions or {}).get("delete_abnormal_accounts", True)),
+ "dedupe_duplicate_accounts": bool((actions or {}).get("dedupe_duplicate_accounts", True)),
+ }
+ started = time.time()
+ all_accounts = self._list_all_accounts(timeout=timeout, page_size=100)
+
+ error_accounts = [
+ account for account in all_accounts
+ if self._is_abnormal_status(account.get("status"))
+ ]
+
+ error_ids = [
+ self._normalize_account_id(acc.get("id"))
+ for acc in error_accounts
+ ]
+ error_ids = [i for i in error_ids if i is not None]
+ initial_error_ids = set(error_ids)
+
+ refresh_result = {"success_ids": [], "failed_ids": []}
+ if action_flags["refresh_abnormal_accounts"] and error_ids:
+ refresh_result = self._refresh_accounts_parallel(error_ids, timeout=30, workers=8)
+
+ refreshed_ids = list(refresh_result.get("success_ids") or [])
+ refresh_failed_ids = list(refresh_result.get("failed_ids") or [])
+
+ current_accounts = all_accounts
+ current_error_ids = set(initial_error_ids)
+ if refreshed_ids:
+ time.sleep(2)
+ if action_flags["refresh_abnormal_accounts"] and (error_ids or refreshed_ids):
+ current_accounts = self._list_all_accounts(timeout=timeout, page_size=100)
+ current_error_ids = {
+ int(acc_id) for acc_id in (
+ self._normalize_account_id(account.get("id"))
+ for account in current_accounts
+ if self._is_abnormal_status(account.get("status"))
+ ) if isinstance(acc_id, int)
+ }
+ recovered = len(initial_error_ids - current_error_ids)
+
+ dedupe_plan = {
+ "duplicate_groups": 0,
+ "duplicate_accounts": 0,
+ "delete_ids": [],
+ "groups_preview": [],
+ "truncated_groups": 0,
+ }
+ duplicate_delete_ids: List[int] = []
+ if action_flags["dedupe_duplicate_accounts"]:
+ dedupe_plan = self._build_dedupe_plan(current_accounts, details_limit=120)
+ duplicate_delete_ids = [int(i) for i in dedupe_plan["delete_ids"] if isinstance(i, int)]
+ normal_count = len(current_accounts) - len(current_error_ids)
+
+ delete_targets: set[int] = set()
+ if action_flags["delete_abnormal_accounts"]:
+ delete_targets.update(current_error_ids)
+ if action_flags["dedupe_duplicate_accounts"]:
+ delete_targets.update(duplicate_delete_ids)
+ delete_result = self._delete_accounts_parallel(sorted(delete_targets, reverse=True), timeout=timeout, workers=12)
+ deleted_ok = int(delete_result.get("deleted_ok", 0))
+ deleted_fail = int(delete_result.get("deleted_fail", 0))
+ deleted_ok_ids = set(int(i) for i in (delete_result.get("deleted_ok_ids") or []) if isinstance(i, int))
+
+ deleted_from_error = len(deleted_ok_ids & set(current_error_ids))
+ deleted_from_duplicate = len(deleted_ok_ids & set(duplicate_delete_ids))
+
+ elapsed_ms = int((time.time() - started) * 1000)
+
+ return {
+ "actions": action_flags,
+ "total": len(current_accounts), "normal": normal_count,
+ "initial_error_count": len(initial_error_ids),
+ "error_count": len(current_error_ids), "refreshed": recovered,
+ "refresh_attempted": len(error_ids) if action_flags["refresh_abnormal_accounts"] else 0,
+ "refresh_failed": len(refresh_failed_ids),
+ "deleted_ok": deleted_ok, "deleted_fail": deleted_fail,
+ "duplicate_groups": int(dedupe_plan["duplicate_groups"]),
+ "duplicate_accounts": int(dedupe_plan["duplicate_accounts"]),
+ "duplicate_to_delete": len(duplicate_delete_ids),
+ "deleted_from_error": deleted_from_error,
+ "deleted_from_duplicate": deleted_from_duplicate,
+ "duration_ms": elapsed_ms,
+ }
+
+ def calculate_gap(self, current_candidates: Optional[int] = None) -> int:
+ if current_candidates is None:
+ status = self.get_pool_status()
+ if status.get("error"):
+ raise RuntimeError(f"Sub2Api 池状态查询失败: {status['error']}")
+ current_candidates = status["candidates"]
+ return max(0, self.min_candidates - current_candidates)
diff --git a/openai_pool_orchestrator/register.py b/openai_pool_orchestrator/register.py
new file mode 100755
index 0000000..89a8e8b
--- /dev/null
+++ b/openai_pool_orchestrator/register.py
@@ -0,0 +1,2120 @@
+import json
+import os
+import re
+import sys
+import time
+import uuid
+import math
+import random
+import string
+import secrets
+import socket
+import hashlib
+import base64
+import threading
+import argparse
+import queue
+import tempfile
+from http.cookies import SimpleCookie
+from datetime import datetime, timezone, timedelta
+from urllib.parse import urlparse, parse_qs, urlencode, quote
+from dataclasses import dataclass
+from typing import Any, Dict, Optional, Callable
+import urllib.parse
+import urllib.request
+import urllib.error
+
+from curl_cffi import requests
+
+# ==========================================
+# 日志事件发射器
+# ==========================================
+
+
+class EventEmitter:
+ """
+ 将注册流程中的日志事件发射到队列,供 SSE 消费。
+ 同时支持 CLI 模式(直接 print)。
+ """
+
+ def __init__(
+ self,
+ q: Optional[queue.Queue] = None,
+ cli_mode: bool = False,
+ defaults: Optional[Dict[str, Any]] = None,
+ ):
+ self._q = q
+ self._cli_mode = cli_mode
+ self._defaults = dict(defaults or {})
+
+ def emit(self, level: str, message: str, step: str = "", **extra: Any) -> None:
+ """
+ level: "info" | "success" | "error" | "warn"
+ step: 可选的流程阶段标识,如 "check_proxy" / "create_email" 等
+ """
+ ts = datetime.now().strftime("%H:%M:%S")
+ event = {
+ "ts": ts,
+ "level": level,
+ "message": message,
+ "step": step,
+ }
+ if self._defaults:
+ event.update(self._defaults)
+ if extra:
+ event.update({k: v for k, v in extra.items() if v is not None})
+ if self._cli_mode:
+ prefix_map = {
+ "info": "[*]",
+ "success": "[+]",
+ "error": "[Error]",
+ "warn": "[!]",
+ }
+ prefix = prefix_map.get(level, "[*]")
+ print(f"{prefix} {message}")
+ if self._q is not None:
+ try:
+ self._q.put_nowait(event)
+ except queue.Full:
+ pass
+
+ def bind(self, **defaults: Any) -> "EventEmitter":
+ merged = dict(self._defaults)
+ merged.update({k: v for k, v in defaults.items() if v is not None})
+ return EventEmitter(q=self._q, cli_mode=self._cli_mode, defaults=merged)
+
+ def info(self, msg: str, step: str = "", **extra: Any) -> None:
+ self.emit("info", msg, step, **extra)
+
+ def success(self, msg: str, step: str = "", **extra: Any) -> None:
+ self.emit("success", msg, step, **extra)
+
+ def error(self, msg: str, step: str = "", **extra: Any) -> None:
+ self.emit("error", msg, step, **extra)
+
+ def warn(self, msg: str, step: str = "", **extra: Any) -> None:
+ self.emit("warn", msg, step, **extra)
+
+
+# 默认 CLI 发射器(兼容直接运行)
+_cli_emitter = EventEmitter(cli_mode=True)
+
+
+# ==========================================
+# Mail.tm 临时邮箱 API
+# ==========================================
+
+MAILTM_BASE = "https://api.mail.tm"
+DEFAULT_PROXY_POOL_URL = "https://zenproxy.top/api/fetch"
+DEFAULT_PROXY_POOL_AUTH_MODE = "query"
+DEFAULT_PROXY_POOL_API_KEY = "19c0ec43-8f76-4c97-81bc-bcda059eeba4"
+DEFAULT_PROXY_POOL_COUNT = 1
+DEFAULT_PROXY_POOL_COUNTRY = "US"
+DEFAULT_HTTP_VERSION = "v2"
+H3_PROXY_ERROR_HINT = "HTTP/3 is not supported over an HTTP proxy"
+TRANSIENT_TLS_ERROR_HINTS = (
+ "curl: (35)",
+ "TLS connect error",
+ "OPENSSL_internal:invalid library",
+ "SSL_ERROR_SYSCALL",
+)
+TRANSIENT_TLS_RETRY_COUNT = 2
+POOL_RELAY_RETRIES = 2
+POOL_PROXY_FETCH_RETRIES = 3
+POOL_RELAY_REQUEST_RETRIES = 2
+
+
+def _is_transient_tls_error(exc: Exception | str) -> bool:
+ message = str(exc or "")
+ return any(hint in message for hint in TRANSIENT_TLS_ERROR_HINTS)
+
+
+def _call_with_http_fallback(request_func, url: str, **kwargs: Any):
+ """
+ curl_cffi 在某些站点可能优先尝试 H3,遇到 HTTP 代理不支持时自动降级到 HTTP/1.1 重试。
+ 对 curl TLS 握手异常(如 curl: (35))也进行有限重试,并优先降级到 HTTP/1.1。
+ """
+ try:
+ return request_func(url, **kwargs)
+ except Exception as exc:
+ message = str(exc)
+ if H3_PROXY_ERROR_HINT in message:
+ retry_kwargs = dict(kwargs)
+ retry_kwargs["http_version"] = "v1"
+ return request_func(url, **retry_kwargs)
+ if not _is_transient_tls_error(message):
+ raise
+
+ last_exc: Exception = exc
+ candidate_kwargs_list = [dict(kwargs)]
+ if str(kwargs.get("http_version") or "").strip().lower() != "v1":
+ retry_kwargs = dict(kwargs)
+ retry_kwargs["http_version"] = "v1"
+ candidate_kwargs_list.append(retry_kwargs)
+
+ for candidate_kwargs in candidate_kwargs_list:
+ for attempt in range(TRANSIENT_TLS_RETRY_COUNT):
+ time.sleep(min(0.35 * (attempt + 1), 1.0))
+ try:
+ return request_func(url, **candidate_kwargs)
+ except Exception as retry_exc:
+ last_exc = retry_exc
+ retry_message = str(retry_exc)
+ if H3_PROXY_ERROR_HINT in retry_message and str(candidate_kwargs.get("http_version") or "").strip().lower() != "v1":
+ candidate_kwargs = dict(candidate_kwargs)
+ candidate_kwargs["http_version"] = "v1"
+ continue
+ if not _is_transient_tls_error(retry_message):
+ raise
+ raise last_exc
+
+def _normalize_proxy_value(proxy_value: Any) -> str:
+ value = str(proxy_value or "").strip().strip('"').strip("'")
+ if not value:
+ return ""
+ if value.startswith("{") or value.startswith("[") or value.startswith("<"):
+ return ""
+ if "://" in value:
+ return value
+ if ":" not in value:
+ return ""
+ return f"http://{value}"
+
+
+def _to_proxies_dict(proxy_value: str) -> Optional[Dict[str, str]]:
+ normalized = _normalize_proxy_value(proxy_value)
+ if not normalized:
+ return None
+ return {"http": normalized, "https": normalized}
+
+
+def _build_proxy_from_host_port(host: Any, port: Any, proxy_type: Any = "") -> str:
+ host_value = str(host or "").strip()
+ port_value = str(port or "").strip()
+ if not host_value or not port_value:
+ return ""
+ proxy_type_value = str(proxy_type or "").strip().lower()
+ if proxy_type_value in ("socks5", "socks", "shadowsocks"):
+ return _normalize_proxy_value(f"socks5://{host_value}:{port_value}")
+ return _normalize_proxy_value(f"http://{host_value}:{port_value}")
+
+
+def _pool_host_from_api_url(api_url: str) -> str:
+ raw = str(api_url or "").strip()
+ if not raw:
+ return ""
+ if "://" not in raw:
+ raw = "https://" + raw
+ try:
+ parsed = urlparse(raw)
+ return str(parsed.hostname or "").strip()
+ except Exception:
+ return ""
+
+
+def _pool_relay_url_from_fetch_url(api_url: str) -> str:
+ raw = str(api_url or "").strip()
+ if not raw:
+ return ""
+ if "://" not in raw:
+ raw = "https://" + raw
+ try:
+ parsed = urlparse(raw)
+ scheme = parsed.scheme or "https"
+ netloc = parsed.netloc
+ if not netloc:
+ return ""
+ return f"{scheme}://{netloc}/api/relay"
+ except Exception:
+ return ""
+
+
+def _trace_via_pool_relay(pool_cfg: Dict[str, Any]) -> str:
+ relay_url = _pool_relay_url_from_fetch_url(str(pool_cfg.get("api_url") or ""))
+ if not relay_url:
+ raise RuntimeError("代理池 relay 地址解析失败")
+
+ api_key = str(pool_cfg.get("api_key") or DEFAULT_PROXY_POOL_API_KEY).strip() or DEFAULT_PROXY_POOL_API_KEY
+ country = str(pool_cfg.get("country") or DEFAULT_PROXY_POOL_COUNTRY).strip().upper() or DEFAULT_PROXY_POOL_COUNTRY
+ timeout = int(pool_cfg.get("timeout_seconds") or 10)
+ timeout = max(8, min(timeout, 30))
+
+ params = {
+ "api_key": api_key,
+ "url": "https://cloudflare.com/cdn-cgi/trace",
+ "country": country,
+ }
+ retry_count = max(1, int(pool_cfg.get("relay_retries") or POOL_RELAY_RETRIES))
+ last_error = ""
+ for i in range(retry_count):
+ try:
+ resp = _call_with_http_fallback(
+ requests.get,
+ relay_url,
+ params=params,
+ impersonate="chrome",
+ timeout=timeout,
+ )
+ if resp.status_code == 200:
+ return str(resp.text or "")
+ last_error = f"HTTP {resp.status_code}"
+ except Exception as exc:
+ last_error = str(exc)
+ if i < retry_count - 1:
+ time.sleep(min(0.3 * (i + 1), 1.0))
+ raise RuntimeError(f"代理池 relay 请求失败: {last_error or 'unknown error'}")
+def _extract_proxy_from_obj(obj: Any, relay_host: str = "") -> str:
+ if isinstance(obj, str):
+ return _normalize_proxy_value(obj)
+ if isinstance(obj, (list, tuple)):
+ for item in obj:
+ proxy = _extract_proxy_from_obj(item, relay_host)
+ if proxy:
+ return proxy
+ return ""
+ if isinstance(obj, dict):
+ local_port = obj.get("local_port")
+ if local_port in (None, ""):
+ local_port = obj.get("localPort")
+ if local_port not in (None, ""):
+ # ZenProxy 文档中的 local_port 是代理绑定端口,优先使用 api_url 主机名。
+ if relay_host:
+ proxy = _normalize_proxy_value(f"http://{relay_host}:{local_port}")
+ if proxy:
+ return proxy
+ proxy = _normalize_proxy_value(f"http://127.0.0.1:{local_port}")
+ if proxy:
+ return proxy
+
+ host = str(obj.get("ip") or obj.get("host") or obj.get("server") or "").strip()
+ port = str(obj.get("port") or "").strip()
+ proxy_type = obj.get("type") or obj.get("protocol") or obj.get("scheme") or ""
+ if host and port:
+ proxy = _build_proxy_from_host_port(host, port, proxy_type)
+ if proxy:
+ return proxy
+
+ for key in ("proxy", "proxy_url", "url", "value", "result", "data", "proxy_list", "list", "proxies"):
+ if key in obj:
+ proxy = _extract_proxy_from_obj(obj.get(key), relay_host)
+ if proxy:
+ return proxy
+
+ for value in obj.values():
+ proxy = _extract_proxy_from_obj(value, relay_host)
+ if proxy:
+ return proxy
+ return ""
+
+
+def _proxy_tcp_reachable(proxy_url: str, timeout_seconds: float = 1.2) -> bool:
+ value = str(proxy_url or "").strip()
+ if not value:
+ return False
+ if "://" not in value:
+ value = "http://" + value
+ try:
+ parsed = urlparse(value)
+ host = str(parsed.hostname or "").strip()
+ port = int(parsed.port or 0)
+ except Exception:
+ return False
+ if not host or port <= 0:
+ return False
+ try:
+ with socket.create_connection((host, port), timeout=timeout_seconds):
+ return True
+ except Exception:
+ return False
+
+
+def _fetch_proxy_from_pool(pool_cfg: Dict[str, Any]) -> str:
+ enabled = bool(pool_cfg.get("enabled"))
+ if not enabled:
+ return ""
+
+ api_url = str(pool_cfg.get("api_url") or DEFAULT_PROXY_POOL_URL).strip() or DEFAULT_PROXY_POOL_URL
+ auth_mode = str(pool_cfg.get("auth_mode") or DEFAULT_PROXY_POOL_AUTH_MODE).strip().lower()
+ if auth_mode not in ("header", "query"):
+ auth_mode = DEFAULT_PROXY_POOL_AUTH_MODE
+ api_key = str(pool_cfg.get("api_key") or DEFAULT_PROXY_POOL_API_KEY).strip() or DEFAULT_PROXY_POOL_API_KEY
+ relay_host = str(pool_cfg.get("relay_host") or "").strip()
+ if not relay_host:
+ relay_host = _pool_host_from_api_url(api_url)
+ try:
+ count = int(pool_cfg.get("count") or DEFAULT_PROXY_POOL_COUNT)
+ except (TypeError, ValueError):
+ count = DEFAULT_PROXY_POOL_COUNT
+ count = max(1, min(count, 20))
+ country = str(pool_cfg.get("country") or DEFAULT_PROXY_POOL_COUNTRY).strip().upper() or DEFAULT_PROXY_POOL_COUNTRY
+ timeout = int(pool_cfg.get("timeout_seconds") or 10)
+ timeout = max(3, min(timeout, 30))
+
+ headers: Dict[str, str] = {}
+ params: Dict[str, str] = {"count": str(count), "country": country}
+ if auth_mode == "query":
+ params["api_key"] = api_key
+ else:
+ headers["Authorization"] = f"Bearer {api_key}"
+
+ resp = _call_with_http_fallback(
+ requests.get,
+ api_url,
+ headers=headers or None,
+ params=params or None,
+ http_version=DEFAULT_HTTP_VERSION,
+ impersonate="chrome",
+ timeout=timeout,
+ )
+ if resp.status_code != 200:
+ raise RuntimeError(f"代理池请求失败: HTTP {resp.status_code}")
+
+ proxy = ""
+ try:
+ payload = resp.json()
+ if isinstance(payload, dict):
+ proxies = payload.get("proxies")
+ if isinstance(proxies, list):
+ for item in proxies:
+ proxy = _extract_proxy_from_obj(item, relay_host)
+ if proxy:
+ break
+ if not proxy:
+ proxy = _extract_proxy_from_obj(payload, relay_host)
+ except Exception:
+ proxy = ""
+
+ if not proxy:
+ proxy = _normalize_proxy_value(resp.text)
+ if not proxy:
+ raise RuntimeError("代理池响应中未找到可用代理")
+ return proxy
+
+
+def _resolve_request_proxies(
+ default_proxies: Any = None,
+ proxy_selector: Optional[Callable[[], Any]] = None,
+) -> Any:
+ if not proxy_selector:
+ return default_proxies
+ try:
+ selected = proxy_selector()
+ if selected is not None:
+ return selected
+ except Exception:
+ pass
+ return default_proxies
+
+
+def _mailtm_headers(*, token: str = "", use_json: bool = False) -> Dict[str, str]:
+ headers = {"Accept": "application/json"}
+ if use_json:
+ headers["Content-Type"] = "application/json"
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ return headers
+
+
+def _mailtm_domains(proxies: Any = None) -> list[str]:
+ resp = _call_with_http_fallback(
+ requests.get,
+ f"{MAILTM_BASE}/domains",
+ headers=_mailtm_headers(),
+ proxies=proxies,
+ http_version=DEFAULT_HTTP_VERSION,
+ impersonate="chrome",
+ timeout=15,
+ )
+ if resp.status_code != 200:
+ raise RuntimeError(f"获取 Mail.tm 域名失败,状态码: {resp.status_code}")
+
+ data = resp.json()
+ domains = []
+ if isinstance(data, list):
+ items = data
+ elif isinstance(data, dict):
+ items = data.get("hydra:member") or data.get("items") or []
+ else:
+ items = []
+
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ domain = str(item.get("domain") or "").strip()
+ is_active = item.get("isActive", True)
+ is_private = item.get("isPrivate", False)
+ if domain and is_active and not is_private:
+ domains.append(domain)
+
+ return domains
+
+
+def get_email_and_token(
+ proxies: Any = None,
+ emitter: EventEmitter = _cli_emitter,
+ proxy_selector: Optional[Callable[[], Any]] = None,
+) -> tuple[str, str]:
+ """创建 Mail.tm 邮箱并获取 Bearer Token"""
+ try:
+ domains = _mailtm_domains(_resolve_request_proxies(proxies, proxy_selector))
+ if not domains:
+ emitter.error("Mail.tm 没有可用域名", step="create_email")
+ return "", ""
+ domain = random.choice(domains)
+
+ for _ in range(5):
+ local = f"oc{secrets.token_hex(5)}"
+ email = f"{local}@{domain}"
+ password = secrets.token_urlsafe(18)
+
+ create_resp = _call_with_http_fallback(
+ requests.post,
+ f"{MAILTM_BASE}/accounts",
+ headers=_mailtm_headers(use_json=True),
+ json={"address": email, "password": password},
+ proxies=_resolve_request_proxies(proxies, proxy_selector),
+ http_version=DEFAULT_HTTP_VERSION,
+ impersonate="chrome",
+ timeout=15,
+ )
+
+ if create_resp.status_code not in (200, 201):
+ continue
+
+ token_resp = _call_with_http_fallback(
+ requests.post,
+ f"{MAILTM_BASE}/token",
+ headers=_mailtm_headers(use_json=True),
+ json={"address": email, "password": password},
+ proxies=_resolve_request_proxies(proxies, proxy_selector),
+ http_version=DEFAULT_HTTP_VERSION,
+ impersonate="chrome",
+ timeout=15,
+ )
+
+ if token_resp.status_code == 200:
+ token = str(token_resp.json().get("token") or "").strip()
+ if token:
+ return email, token
+
+ emitter.error("Mail.tm 邮箱创建成功但获取 Token 失败", step="create_email")
+ return "", ""
+ except Exception as e:
+ emitter.error(f"请求 Mail.tm API 出错: {e}", step="create_email")
+ return "", ""
+
+
+def get_oai_code(
+ token: str, email: str, proxies: Any = None, emitter: EventEmitter = _cli_emitter,
+ stop_event: Optional[threading.Event] = None,
+ proxy_selector: Optional[Callable[[], Any]] = None,
+) -> str:
+ """使用 Mail.tm Token 轮询获取 OpenAI 验证码"""
+ url_list = f"{MAILTM_BASE}/messages"
+ regex = r"(? str:
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
+
+
+def _sha256_b64url_no_pad(s: str) -> str:
+ return _b64url_no_pad(hashlib.sha256(s.encode("ascii")).digest())
+
+
+def _random_state(nbytes: int = 16) -> str:
+ return secrets.token_urlsafe(nbytes)
+
+
+def _pkce_verifier() -> str:
+ return secrets.token_urlsafe(64)
+
+
+def _parse_callback_url(callback_url: str) -> Dict[str, str]:
+ candidate = callback_url.strip()
+ if not candidate:
+ return {"code": "", "state": "", "error": "", "error_description": ""}
+
+ if "://" not in candidate:
+ if candidate.startswith("?"):
+ candidate = f"http://localhost{candidate}"
+ elif any(ch in candidate for ch in "/?#") or ":" in candidate:
+ candidate = f"http://{candidate}"
+ elif "=" in candidate:
+ candidate = f"http://localhost/?{candidate}"
+
+ parsed = urllib.parse.urlparse(candidate)
+ query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
+ fragment = urllib.parse.parse_qs(parsed.fragment, keep_blank_values=True)
+
+ for key, values in fragment.items():
+ if key not in query or not query[key] or not (query[key][0] or "").strip():
+ query[key] = values
+
+ def get1(k: str) -> str:
+ v = query.get(k, [""])
+ return (v[0] or "").strip()
+
+ code = get1("code")
+ state = get1("state")
+ error = get1("error")
+ error_description = get1("error_description")
+
+ if code and not state and "#" in code:
+ code, state = code.split("#", 1)
+
+ if not error and error_description:
+ error, error_description = error_description, ""
+
+ return {
+ "code": code,
+ "state": state,
+ "error": error,
+ "error_description": error_description,
+ }
+
+
+def _jwt_claims_no_verify(id_token: str) -> Dict[str, Any]:
+ if not id_token or id_token.count(".") < 2:
+ return {}
+ payload_b64 = id_token.split(".")[1]
+ pad = "=" * ((4 - (len(payload_b64) % 4)) % 4)
+ try:
+ payload = base64.urlsafe_b64decode((payload_b64 + pad).encode("ascii"))
+ return json.loads(payload.decode("utf-8"))
+ except Exception:
+ return {}
+
+
+def _decode_jwt_segment(seg: str) -> Dict[str, Any]:
+ raw = (seg or "").strip()
+ if not raw:
+ return {}
+ pad = "=" * ((4 - (len(raw) % 4)) % 4)
+ try:
+ decoded = base64.urlsafe_b64decode((raw + pad).encode("ascii"))
+ return json.loads(decoded.decode("utf-8"))
+ except Exception:
+ return {}
+
+
+def _to_int(v: Any) -> int:
+ try:
+ return int(v)
+ except (TypeError, ValueError):
+ return 0
+
+
+def _post_form(
+ url: str,
+ data: Dict[str, str],
+ timeout: int = 30,
+ proxy: str = "",
+) -> Dict[str, Any]:
+ body = urllib.parse.urlencode(data).encode("utf-8")
+ req = urllib.request.Request(
+ url,
+ data=body,
+ method="POST",
+ headers={
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ },
+ )
+ handlers = []
+ normalized_proxy = _normalize_proxy_value(proxy)
+ if normalized_proxy:
+ handlers.append(urllib.request.ProxyHandler({"http": normalized_proxy, "https": normalized_proxy}))
+ opener = urllib.request.build_opener(*handlers)
+ try:
+ with opener.open(req, timeout=timeout) as resp:
+ raw = resp.read()
+ if resp.status != 200:
+ raise RuntimeError(
+ f"token exchange failed: {resp.status}: {raw.decode('utf-8', 'replace')}"
+ )
+ return json.loads(raw.decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ raw = exc.read()
+ raise RuntimeError(
+ f"token exchange failed: {exc.code}: {raw.decode('utf-8', 'replace')}"
+ ) from exc
+
+
+def _build_token_result(token_payload: Dict[str, Any], account_password: str = "") -> str:
+ access_token = str(token_payload.get("access_token") or "").strip()
+ refresh_token = str(token_payload.get("refresh_token") or "").strip()
+ id_token = str(token_payload.get("id_token") or "").strip()
+ expires_in = _to_int(token_payload.get("expires_in"))
+
+ missing_fields = [
+ name for name, value in (
+ ("access_token", access_token),
+ ("refresh_token", refresh_token),
+ ("id_token", id_token),
+ ) if not value
+ ]
+ if missing_fields:
+ raise ValueError(f"token exchange missing fields: {', '.join(missing_fields)}")
+
+ claims = _jwt_claims_no_verify(id_token)
+ email = str(claims.get("email") or "").strip()
+ auth_claims = claims.get("https://api.openai.com/auth") or {}
+ account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
+ if not email or not account_id:
+ raise ValueError("token exchange missing email/account_id in id_token")
+
+ now = int(time.time())
+ expired_rfc3339 = time.strftime(
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + max(expires_in, 0))
+ )
+ now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
+
+ config = {
+ "id_token": id_token,
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "account_id": account_id,
+ "last_refresh": now_rfc3339,
+ "expires_at": expired_rfc3339,
+ "email": email,
+ "type": "codex",
+ "expired": expired_rfc3339,
+ }
+ if account_password:
+ config["account_password"] = account_password
+ return json.dumps(config, ensure_ascii=False, separators=(",", ":"))
+
+
+def _write_text_atomic(file_path: str, content: str) -> None:
+ directory = os.path.dirname(file_path) or "."
+ os.makedirs(directory, exist_ok=True)
+ fd, tmp_path = tempfile.mkstemp(prefix=".tmp_", suffix=".json", dir=directory)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ handle.write(content)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(tmp_path, file_path)
+ finally:
+ try:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+ except OSError:
+ pass
+
+
+@dataclass(frozen=True)
+class OAuthStart:
+ auth_url: str
+ state: str
+ code_verifier: str
+ redirect_uri: str
+
+
+def generate_oauth_url(
+ *, redirect_uri: str = DEFAULT_REDIRECT_URI, scope: str = DEFAULT_SCOPE
+) -> OAuthStart:
+ state = _random_state()
+ code_verifier = _pkce_verifier()
+ code_challenge = _sha256_b64url_no_pad(code_verifier)
+
+ params = {
+ "client_id": CLIENT_ID,
+ "response_type": "code",
+ "redirect_uri": redirect_uri,
+ "scope": scope,
+ "state": state,
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256",
+ "prompt": "login",
+ "id_token_add_organizations": "true",
+ "codex_cli_simplified_flow": "true",
+ }
+ auth_url = f"{AUTH_URL}?{urllib.parse.urlencode(params)}"
+ return OAuthStart(
+ auth_url=auth_url,
+ state=state,
+ code_verifier=code_verifier,
+ redirect_uri=redirect_uri,
+ )
+
+
+def submit_callback_url(
+ *,
+ callback_url: str,
+ expected_state: str,
+ code_verifier: str,
+ redirect_uri: str = DEFAULT_REDIRECT_URI,
+ proxy: str = "",
+) -> str:
+ cb = _parse_callback_url(callback_url)
+ if cb["error"]:
+ desc = cb["error_description"]
+ raise RuntimeError(f"oauth error: {cb['error']}: {desc}".strip())
+
+ if not cb["code"]:
+ raise ValueError("callback url missing ?code=")
+ if not cb["state"]:
+ raise ValueError("callback url missing ?state=")
+ if cb["state"] != expected_state:
+ raise ValueError("state mismatch")
+
+ token_resp = _post_form(
+ TOKEN_URL,
+ {
+ "grant_type": "authorization_code",
+ "client_id": CLIENT_ID,
+ "code": cb["code"],
+ "redirect_uri": redirect_uri,
+ "code_verifier": code_verifier,
+ },
+ proxy=proxy,
+ )
+
+ return _build_token_result(token_resp)
+
+
+# ==========================================
+# 核心注册逻辑
+# ==========================================
+
+from . import TOKENS_DIR as _PKG_TOKENS_DIR
+
+TOKENS_DIR = str(_PKG_TOKENS_DIR)
+
+
+def run(
+ proxy: Optional[str],
+ emitter: EventEmitter = _cli_emitter,
+ stop_event: Optional[threading.Event] = None,
+ mail_provider=None,
+ proxy_pool_config: Optional[Dict[str, Any]] = None,
+) -> Optional[str]:
+ static_proxy = _normalize_proxy_value(proxy)
+ static_proxies: Any = _to_proxies_dict(static_proxy)
+
+ pool_cfg_raw = proxy_pool_config or {}
+ pool_cfg = {
+ "enabled": bool(pool_cfg_raw.get("enabled", False)),
+ "api_url": str(pool_cfg_raw.get("api_url") or DEFAULT_PROXY_POOL_URL).strip() or DEFAULT_PROXY_POOL_URL,
+ "auth_mode": str(pool_cfg_raw.get("auth_mode") or DEFAULT_PROXY_POOL_AUTH_MODE).strip().lower() or DEFAULT_PROXY_POOL_AUTH_MODE,
+ "api_key": str(pool_cfg_raw.get("api_key") or DEFAULT_PROXY_POOL_API_KEY).strip() or DEFAULT_PROXY_POOL_API_KEY,
+ "count": pool_cfg_raw.get("count", DEFAULT_PROXY_POOL_COUNT),
+ "country": str(pool_cfg_raw.get("country") or DEFAULT_PROXY_POOL_COUNTRY).strip().upper() or DEFAULT_PROXY_POOL_COUNTRY,
+ "timeout_seconds": int(pool_cfg_raw.get("timeout_seconds") or 10),
+ }
+ if pool_cfg["auth_mode"] not in ("header", "query"):
+ pool_cfg["auth_mode"] = DEFAULT_PROXY_POOL_AUTH_MODE
+ try:
+ pool_cfg["count"] = max(1, min(int(pool_cfg.get("count") or DEFAULT_PROXY_POOL_COUNT), 20))
+ except (TypeError, ValueError):
+ pool_cfg["count"] = DEFAULT_PROXY_POOL_COUNT
+
+ last_pool_proxy = ""
+ pool_fail_streak = 0
+ warned_fallback = False
+
+ def _next_proxy_value() -> str:
+ nonlocal last_pool_proxy, pool_fail_streak, warned_fallback
+ if pool_cfg["enabled"]:
+ max_fetch_retries = max(1, int(pool_cfg.get("fetch_retries") or POOL_PROXY_FETCH_RETRIES))
+ last_error = ""
+ for _ in range(max_fetch_retries):
+ try:
+ fetched = _fetch_proxy_from_pool(pool_cfg)
+ if fetched and not _proxy_tcp_reachable(fetched):
+ last_error = f"代理池代理不可达: {fetched}"
+ continue
+ last_pool_proxy = fetched
+ pool_fail_streak = 0
+ warned_fallback = False
+ return fetched
+ except Exception as e:
+ last_error = str(e)
+
+ pool_fail_streak += 1
+ if static_proxy:
+ if not warned_fallback:
+ emitter.warn(f"代理池不可用,回退固定代理: {last_error or 'unknown error'}", step="check_proxy")
+ warned_fallback = True
+ return static_proxy
+ if pool_fail_streak <= 3:
+ emitter.warn(f"代理池不可用: {last_error or 'unknown error'}", step="check_proxy")
+ return ""
+ return static_proxy
+ def _next_proxies() -> Any:
+ proxy_value = _next_proxy_value()
+ return _to_proxies_dict(proxy_value)
+
+ # 随机 Chrome 指纹,避免 OpenAI 反机器人检测
+ _chrome_profiles = [
+ {"major": 119, "imp": "chrome119", "build": 6045, "patch": (123, 200),
+ "sec": '"Google Chrome";v="119", "Chromium";v="119", "Not?A_Brand";v="24"'},
+ {"major": 120, "imp": "chrome120", "build": 6099, "patch": (62, 200),
+ "sec": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"'},
+ {"major": 123, "imp": "chrome123", "build": 6312, "patch": (46, 170),
+ "sec": '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"'},
+ {"major": 124, "imp": "chrome124", "build": 6367, "patch": (60, 180),
+ "sec": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"'},
+ ]
+ _cp = random.choice(_chrome_profiles)
+ _chrome_full = f"{_cp['major']}.0.{_cp['build']}.{random.randint(*_cp['patch'])}"
+ _chrome_ua = f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{_chrome_full} Safari/537.36"
+
+ s = requests.Session(impersonate=_cp["imp"])
+ s.headers.update({
+ "User-Agent": _chrome_ua,
+ "Accept-Language": random.choice(["en-US,en;q=0.9", "en-US,en;q=0.9,zh-CN;q=0.8", "en,en-US;q=0.9"]),
+ "sec-ch-ua": _cp["sec"],
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-ch-ua-arch": '"x86"',
+ "sec-ch-ua-bitness": '"64"',
+ "sec-ch-ua-full-version": f'"{_chrome_full}"',
+ "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"',
+ })
+
+ def _trace_headers() -> Dict[str, str]:
+ """生成 DataDog trace headers,模拟真实浏览器监控"""
+ trace_id = random.randint(10**17, 10**18 - 1)
+ parent_id = random.randint(10**17, 10**18 - 1)
+ tp = f"00-{uuid.uuid4().hex}-{format(parent_id, '016x')}-01"
+ return {
+ "traceparent": tp, "tracestate": "dd=s:1;o:rum",
+ "x-datadog-origin": "rum", "x-datadog-sampling-priority": "1",
+ "x-datadog-trace-id": str(trace_id), "x-datadog-parent-id": str(parent_id),
+ }
+ pool_relay_url = _pool_relay_url_from_fetch_url(str(pool_cfg.get("api_url") or ""))
+ pool_relay_enabled = bool(pool_cfg["enabled"] and pool_relay_url)
+ relay_cookie_jar: Dict[str, str] = {}
+ pool_relay_api_key = str(pool_cfg.get("api_key") or DEFAULT_PROXY_POOL_API_KEY).strip() or DEFAULT_PROXY_POOL_API_KEY
+ pool_relay_country = str(pool_cfg.get("country") or DEFAULT_PROXY_POOL_COUNTRY).strip().upper() or DEFAULT_PROXY_POOL_COUNTRY
+ relay_fallback_warned = False
+ relay_bypass_openai_hosts = False
+ openai_relay_probe_done = False
+ mail_proxy_selector = None if pool_relay_enabled else _next_proxy_value
+ mail_proxies_selector = None if pool_relay_enabled else _next_proxies
+
+ def _fallback_proxies_for_relay_failure() -> Any:
+ if static_proxy:
+ return _to_proxies_dict(static_proxy)
+ return None
+
+ def _target_host(target_url: str) -> str:
+ return str(urlparse(str(target_url or "")).hostname or "").strip().lower()
+
+ def _is_openai_like_host(host: str) -> bool:
+ return bool(host) and (host.endswith("openai.com") or host.endswith("chatgpt.com"))
+
+ def _should_bypass_relay_for_target(target_url: str) -> bool:
+ host = _target_host(target_url)
+ return relay_bypass_openai_hosts and _is_openai_like_host(host)
+
+ def _warn_relay_fallback(reason: str, target_url: str) -> None:
+ nonlocal relay_fallback_warned, relay_bypass_openai_hosts
+ host = _target_host(target_url) or str(target_url or "?")
+ if _is_openai_like_host(host):
+ relay_bypass_openai_hosts = True
+ if relay_fallback_warned:
+ return
+ if static_proxy:
+ emitter.warn(f"代理池 relay 对 {host} 不可用,回退固定代理: {reason}", step="check_proxy")
+ else:
+ emitter.warn(f"代理池 relay 对 {host} 不可用,回退直连: {reason}", step="check_proxy")
+ relay_fallback_warned = True
+
+ def _update_relay_cookie_jar(resp: Any) -> None:
+ try:
+ for k, v in (resp.cookies or {}).items():
+ key = str(k or "").strip()
+ if key:
+ relay_cookie_jar[key] = str(v or "")
+ except Exception:
+ pass
+ set_cookie_values: list[str] = []
+ try:
+ values = resp.headers.get_list("set-cookie") # type: ignore[attr-defined]
+ if values:
+ set_cookie_values.extend(str(v or "") for v in values if str(v or "").strip())
+ except Exception:
+ pass
+ if not set_cookie_values:
+ try:
+ set_cookie_raw = str(resp.headers.get("set-cookie") or "")
+ if set_cookie_raw.strip():
+ set_cookie_values.append(set_cookie_raw)
+ except Exception:
+ pass
+ for set_cookie_raw in set_cookie_values:
+ try:
+ parsed_cookie = SimpleCookie()
+ parsed_cookie.load(set_cookie_raw)
+ for k, morsel in parsed_cookie.items():
+ key = str(k or "").strip()
+ if key:
+ relay_cookie_jar[key] = str(morsel.value or "")
+ except Exception:
+ pass
+ try:
+ for k, v in relay_cookie_jar.items():
+ s.cookies.set(k, v)
+ except Exception:
+ pass
+
+ def _request_via_pool_relay(method: str, target_url: str, **kwargs: Any):
+ if not pool_relay_enabled:
+ raise RuntimeError("代理池 relay 未启用")
+ relay_retries_override = kwargs.pop("_relay_retries", None)
+ relay_params = {
+ "api_key": pool_relay_api_key,
+ "url": str(target_url),
+ "method": str(method or "GET").upper(),
+ "country": pool_relay_country,
+ }
+ target_params = kwargs.pop("params", None)
+ if target_params:
+ query_text = urlencode(target_params, doseq=True)
+ if query_text:
+ separator = "&" if "?" in relay_params["url"] else "?"
+ relay_params["url"] = f"{relay_params['url']}{separator}{query_text}"
+
+ headers = dict(kwargs.pop("headers", {}) or {})
+ if relay_cookie_jar and not any(str(k).lower() == "cookie" for k in headers.keys()):
+ headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in relay_cookie_jar.items())
+ kwargs.pop("proxies", None)
+ kwargs.setdefault("impersonate", "chrome")
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("timeout", 20)
+
+ method_upper = relay_params["method"]
+ retry_count = max(
+ 1,
+ int(
+ relay_retries_override
+ if relay_retries_override is not None
+ else (pool_cfg.get("relay_request_retries") or POOL_RELAY_REQUEST_RETRIES)
+ ),
+ )
+ last_error = ""
+ for i in range(retry_count):
+ try:
+ resp = _call_with_http_fallback(
+ lambda relay_endpoint, **call_kwargs: requests.request(method_upper, relay_endpoint, **call_kwargs),
+ pool_relay_url,
+ params=relay_params,
+ headers=headers or None,
+ **kwargs,
+ )
+ _update_relay_cookie_jar(resp)
+ if resp.status_code >= 500 or resp.status_code == 429:
+ last_error = f"HTTP {resp.status_code}"
+ if i < retry_count - 1:
+ time.sleep(min(0.4 * (i + 1), 1.2))
+ continue
+ return resp
+ except Exception as exc:
+ last_error = str(exc)
+ if i < retry_count - 1:
+ time.sleep(min(0.4 * (i + 1), 1.2))
+ raise RuntimeError(f"代理池 relay 请求失败: {last_error or 'unknown error'}")
+
+ def _ensure_openai_relay_ready() -> None:
+ nonlocal openai_relay_probe_done
+ if not pool_relay_enabled or relay_bypass_openai_hosts or openai_relay_probe_done:
+ return
+ openai_relay_probe_done = True
+ probe_url = "https://auth.openai.com/"
+ try:
+ probe_resp = _request_via_pool_relay(
+ "GET",
+ probe_url,
+ timeout=5,
+ allow_redirects=False,
+ _relay_retries=1,
+ )
+ status = int(probe_resp.status_code or 0)
+ if status < 200 or status >= 400:
+ raise RuntimeError(f"HTTP {status}")
+ emitter.info("代理池 relay OpenAI 预检通过", step="check_proxy")
+ except Exception as exc:
+ _warn_relay_fallback(f"{exc} (OpenAI 预检)", probe_url)
+
+ def _session_get(url: str, **kwargs: Any):
+ if pool_relay_enabled and not _should_bypass_relay_for_target(url):
+ try:
+ relay_resp = _request_via_pool_relay("GET", url, **kwargs)
+ if relay_resp.status_code < 500 and relay_resp.status_code != 429:
+ return relay_resp
+ raise RuntimeError(f"HTTP {relay_resp.status_code}")
+ except Exception as exc:
+ _warn_relay_fallback(str(exc), url)
+ kwargs["proxies"] = _fallback_proxies_for_relay_failure()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("timeout", 20)
+ return _call_with_http_fallback(s.get, url, **kwargs)
+ if pool_relay_enabled and _should_bypass_relay_for_target(url):
+ kwargs["proxies"] = _fallback_proxies_for_relay_failure()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("timeout", 20)
+ return _call_with_http_fallback(s.get, url, **kwargs)
+ kwargs["proxies"] = _next_proxies()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("timeout", 15)
+ return _call_with_http_fallback(s.get, url, **kwargs)
+
+ def _session_post(url: str, **kwargs: Any):
+ if pool_relay_enabled and not _should_bypass_relay_for_target(url):
+ try:
+ relay_resp = _request_via_pool_relay("POST", url, **kwargs)
+ if relay_resp.status_code < 500 and relay_resp.status_code != 429:
+ return relay_resp
+ raise RuntimeError(f"HTTP {relay_resp.status_code}")
+ except Exception as exc:
+ _warn_relay_fallback(str(exc), url)
+ kwargs["proxies"] = _fallback_proxies_for_relay_failure()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("timeout", 20)
+ return _call_with_http_fallback(s.post, url, **kwargs)
+ if pool_relay_enabled and _should_bypass_relay_for_target(url):
+ kwargs["proxies"] = _fallback_proxies_for_relay_failure()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("timeout", 20)
+ return _call_with_http_fallback(s.post, url, **kwargs)
+ kwargs["proxies"] = _next_proxies()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("timeout", 15)
+ return _call_with_http_fallback(s.post, url, **kwargs)
+
+ def _raw_get(url: str, **kwargs: Any):
+ if pool_relay_enabled and not _should_bypass_relay_for_target(url):
+ try:
+ relay_resp = _request_via_pool_relay("GET", url, **kwargs)
+ if relay_resp.status_code < 500 and relay_resp.status_code != 429:
+ return relay_resp
+ raise RuntimeError(f"HTTP {relay_resp.status_code}")
+ except Exception as exc:
+ _warn_relay_fallback(str(exc), url)
+ kwargs["proxies"] = _fallback_proxies_for_relay_failure()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("impersonate", "chrome")
+ kwargs.setdefault("timeout", 20)
+ return _call_with_http_fallback(requests.get, url, **kwargs)
+ if pool_relay_enabled and _should_bypass_relay_for_target(url):
+ kwargs["proxies"] = _fallback_proxies_for_relay_failure()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("impersonate", "chrome")
+ kwargs.setdefault("timeout", 20)
+ return _call_with_http_fallback(requests.get, url, **kwargs)
+ kwargs["proxies"] = _next_proxies()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("impersonate", "chrome")
+ kwargs.setdefault("timeout", 15)
+ return _call_with_http_fallback(requests.get, url, **kwargs)
+
+ def _raw_post(url: str, **kwargs: Any):
+ if pool_relay_enabled and not _should_bypass_relay_for_target(url):
+ try:
+ relay_resp = _request_via_pool_relay("POST", url, **kwargs)
+ if relay_resp.status_code < 500 and relay_resp.status_code != 429:
+ return relay_resp
+ raise RuntimeError(f"HTTP {relay_resp.status_code}")
+ except Exception as exc:
+ _warn_relay_fallback(str(exc), url)
+ kwargs["proxies"] = _fallback_proxies_for_relay_failure()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("impersonate", "chrome")
+ kwargs.setdefault("timeout", 20)
+ return _call_with_http_fallback(requests.post, url, **kwargs)
+ if pool_relay_enabled and _should_bypass_relay_for_target(url):
+ kwargs["proxies"] = _fallback_proxies_for_relay_failure()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("impersonate", "chrome")
+ kwargs.setdefault("timeout", 20)
+ return _call_with_http_fallback(requests.post, url, **kwargs)
+ kwargs["proxies"] = _next_proxies()
+ kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION)
+ kwargs.setdefault("impersonate", "chrome")
+ kwargs.setdefault("timeout", 15)
+ return _call_with_http_fallback(requests.post, url, **kwargs)
+
+ def _submit_callback_url_via_pool_relay(
+ *,
+ callback_url: str,
+ expected_state: str,
+ code_verifier: str,
+ redirect_uri: str = DEFAULT_REDIRECT_URI,
+ ) -> str:
+ cb = _parse_callback_url(callback_url)
+ if cb["error"]:
+ desc = cb["error_description"]
+ raise RuntimeError(f"oauth error: {cb['error']}: {desc}".strip())
+ if not cb["code"]:
+ raise ValueError("callback url missing ?code=")
+ if not cb["state"]:
+ raise ValueError("callback url missing ?state=")
+ if cb["state"] != expected_state:
+ raise ValueError("state mismatch")
+
+ token_resp = _request_via_pool_relay(
+ "POST",
+ TOKEN_URL,
+ headers={
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ },
+ data=urllib.parse.urlencode(
+ {
+ "grant_type": "authorization_code",
+ "client_id": CLIENT_ID,
+ "code": cb["code"],
+ "redirect_uri": redirect_uri,
+ "code_verifier": code_verifier,
+ }
+ ),
+ timeout=30,
+ )
+ if token_resp.status_code != 200:
+ raise RuntimeError(
+ f"token exchange failed: {token_resp.status_code}: {str(token_resp.text or '')[:240]}"
+ )
+ try:
+ token_json = token_resp.json()
+ except Exception:
+ token_json = json.loads(str(token_resp.text or "{}"))
+
+ return _build_token_result(token_json, account_password=account_password)
+
+ def _stopped() -> bool:
+ return stop_event is not None and stop_event.is_set()
+
+ try:
+ # ------- 步骤1:网络环境检查 -------
+ emitter.info("正在检查网络环境...", step="check_proxy")
+ try:
+ trace_text = ""
+ relay_error = ""
+ relay_used = False
+ if pool_cfg["enabled"]:
+ try:
+ trace_text = _trace_via_pool_relay(pool_cfg)
+ relay_used = True
+ except Exception as e:
+ relay_error = str(e)
+ if static_proxy:
+ emitter.warn(f"代理池 relay 检查失败,回退固定代理: {relay_error}", step="check_proxy")
+ else:
+ emitter.warn(f"代理池 relay 检查失败,尝试直连代理: {relay_error}", step="check_proxy")
+ if not trace_text:
+ trace_resp = _session_get("https://cloudflare.com/cdn-cgi/trace", timeout=10)
+ trace_text = trace_resp.text
+ trace = trace_text
+ loc_re = re.search(r"^loc=(.+)$", trace, re.MULTILINE)
+ loc = loc_re.group(1) if loc_re else None
+ ip_re = re.search(r"^ip=(.+)$", trace, re.MULTILINE)
+ current_ip = ip_re.group(1).strip() if ip_re else ""
+ if relay_used:
+ emitter.info("代理池 relay 连通检查成功", step="check_proxy")
+ emitter.info(f"当前 IP 所在地: {loc}", step="check_proxy")
+ if current_ip:
+ emitter.info(f"当前出口 IP: {current_ip}", step="check_proxy")
+ if loc == "CN" or loc == "HK":
+ emitter.error("检查代理哦 — 所在地不支持 (CN/HK)", step="check_proxy")
+ return None
+ emitter.success("网络环境检查通过", step="check_proxy")
+ _ensure_openai_relay_ready()
+ except Exception as e:
+ emitter.error(f"网络连接检查失败: {e}", step="check_proxy")
+ return None
+
+ if _stopped():
+ return None
+
+ # ------- 步骤2:创建临时邮箱 -------
+ if mail_provider is not None:
+ emitter.info("正在创建临时邮箱...", step="create_email")
+ try:
+ email, dev_token = mail_provider.create_mailbox(
+ proxy=static_proxy,
+ proxy_selector=mail_proxy_selector,
+ )
+ except TypeError:
+ email, dev_token = mail_provider.create_mailbox(proxy=static_proxy)
+ else:
+ emitter.info("正在创建 Mail.tm 临时邮箱...", step="create_email")
+ email, dev_token = get_email_and_token(
+ static_proxies,
+ emitter,
+ proxy_selector=mail_proxies_selector,
+ )
+ if not email or not dev_token:
+ emitter.error("临时邮箱创建失败", step="create_email")
+ return None
+ emitter.success(f"临时邮箱创建成功: {email}", step="create_email")
+
+ # 生成随机密码(密码注册流程需要)
+ _pw_chars = string.ascii_letters + string.digits + "!@#$%&*"
+ account_password = "".join(secrets.choice(_pw_chars) for _ in range(16))
+
+ if _stopped():
+ return None
+
+ # ------- 步骤3:通过 chatgpt.com 建立注册会话 -------
+ emitter.info("正在访问 ChatGPT 首页...", step="oauth_init")
+ _chatgpt_base = "https://chatgpt.com"
+
+ # 3a: 访问首页,获取 cookies
+ _session_get(f"{_chatgpt_base}/", timeout=20)
+
+ # 3b: 获取 CSRF Token
+ csrf_resp = _session_get(
+ f"{_chatgpt_base}/api/auth/csrf",
+ headers={"Accept": "application/json", "Referer": f"{_chatgpt_base}/"},
+ timeout=15,
+ )
+ try:
+ csrf_token = csrf_resp.json().get("csrfToken", "")
+ except Exception:
+ csrf_token = ""
+ if not csrf_token:
+ emitter.error("获取 CSRF Token 失败", step="oauth_init")
+ return None
+
+ # 3c: 生成 Device ID
+ did = s.cookies.get("oai-did") or relay_cookie_jar.get("oai-did") or ""
+ if not did:
+ did = str(uuid.uuid4())
+ relay_cookie_jar["oai-did"] = did
+ try:
+ s.cookies.set("oai-did", did, domain="chatgpt.com")
+ except Exception:
+ try:
+ s.cookies.set("oai-did", did)
+ except Exception:
+ pass
+
+ # 3d: Signin 请求,获取 authorize URL
+ auth_session_id = str(uuid.uuid4())
+ signin_params = urllib.parse.urlencode({
+ "prompt": "login",
+ "ext-oai-did": did,
+ "auth_session_logging_id": auth_session_id,
+ "screen_hint": "login_or_signup",
+ "login_hint": email,
+ })
+ signin_resp = _session_post(
+ f"{_chatgpt_base}/api/auth/signin/openai?{signin_params}",
+ headers={
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ "Referer": f"{_chatgpt_base}/",
+ "Origin": _chatgpt_base,
+ },
+ data=urllib.parse.urlencode({
+ "callbackUrl": f"{_chatgpt_base}/",
+ "csrfToken": csrf_token,
+ "json": "true",
+ }),
+ timeout=20,
+ )
+ try:
+ authorize_url = signin_resp.json().get("url", "")
+ except Exception:
+ authorize_url = ""
+ if not authorize_url:
+ emitter.error(
+ f"Signin 获取授权链接失败({signin_resp.status_code}): {str(signin_resp.text or '')[:220]}",
+ step="oauth_init",
+ )
+ return None
+ emitter.info(f"OAuth 初始化状态: {signin_resp.status_code}", step="oauth_init")
+
+ # 3e: 跟随 authorize 重定向,建立 auth.openai.com 会话
+ auth_resp = _session_get(
+ authorize_url,
+ headers={
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Referer": f"{_chatgpt_base}/",
+ "Upgrade-Insecure-Requests": "1",
+ },
+ timeout=20,
+ )
+ final_url = str(auth_resp.url) if hasattr(auth_resp, "url") else ""
+ emitter.info(f"Authorize 重定向完成: {final_url[:120]}", step="oauth_init")
+ emitter.info(f"Device ID: {did}", step="oauth_init")
+
+ if _stopped():
+ return None
+
+ # ------- 步骤4+5:密码注册(合并旧步骤4 Sentinel + 旧步骤5 注册) -------
+ time.sleep(random.uniform(0.5, 1.0))
+ emitter.info("正在提交注册表单(密码模式)...", step="signup")
+ _reg_headers = {
+ "referer": "https://auth.openai.com/create-account/password",
+ "accept": "application/json",
+ "content-type": "application/json",
+ "origin": "https://auth.openai.com",
+ }
+ _reg_headers.update(_trace_headers())
+ signup_resp = _session_post(
+ "https://auth.openai.com/api/accounts/user/register",
+ headers=_reg_headers,
+ json={"username": email, "password": account_password},
+ )
+ emitter.info(f"注册表单提交状态: {signup_resp.status_code}", step="signup")
+ if signup_resp.status_code != 200:
+ emitter.error(
+ f"注册表单提交失败(状态码 {signup_resp.status_code}): {str(signup_resp.text or '')[:220]}",
+ step="signup",
+ )
+ return None
+
+ # ------- 步骤6:发送 OTP 验证码 -------
+ time.sleep(random.uniform(0.3, 0.8))
+ emitter.info("正在发送邮箱验证码...", step="send_otp")
+ otp_resp = _session_get(
+ "https://auth.openai.com/api/accounts/email-otp/send",
+ headers={
+ "referer": "https://auth.openai.com/create-account/password",
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "upgrade-insecure-requests": "1",
+ },
+ )
+ emitter.info(f"验证码发送状态: {otp_resp.status_code}", step="send_otp")
+ if otp_resp.status_code == 409:
+ emitter.warn(f"send_otp 409 响应: {str(otp_resp.text or '')[:220]}", step="send_otp")
+
+ if otp_resp.status_code != 200:
+ emitter.error(f"验证码发送失败(状态码 {otp_resp.status_code}): {str(otp_resp.text or '')[:220]}", step="send_otp")
+ return None
+
+ if _stopped():
+ return None
+
+ # ------- 步骤7:轮询邮箱拿验证码 -------
+ if mail_provider is not None:
+ try:
+ code = mail_provider.wait_for_otp(
+ dev_token,
+ email,
+ proxy=static_proxy,
+ proxy_selector=mail_proxy_selector,
+ stop_event=stop_event,
+ )
+ except TypeError:
+ code = mail_provider.wait_for_otp(
+ dev_token,
+ email,
+ proxy=static_proxy,
+ stop_event=stop_event,
+ )
+ else:
+ code = get_oai_code(
+ dev_token,
+ email,
+ static_proxies,
+ emitter,
+ stop_event,
+ proxy_selector=mail_proxies_selector,
+ )
+ if not code:
+ return None
+
+ if _stopped():
+ return None
+
+ # ------- 步骤8:提交验证码 -------
+ time.sleep(random.uniform(0.3, 0.8))
+ emitter.info("正在验证 OTP...", step="verify_otp")
+ _otp_headers = {
+ "referer": "https://auth.openai.com/email-verification",
+ "accept": "application/json",
+ "content-type": "application/json",
+ "origin": "https://auth.openai.com",
+ }
+ _otp_headers.update(_trace_headers())
+ code_resp = _session_post(
+ "https://auth.openai.com/api/accounts/email-otp/validate",
+ headers=_otp_headers,
+ json={"code": code},
+ )
+ emitter.info(f"验证码校验状态: {code_resp.status_code}", step="verify_otp")
+ if code_resp.status_code != 200:
+ emitter.error(
+ f"验证码校验失败(状态码 {code_resp.status_code}): {str(code_resp.text or '')[:220]}",
+ step="verify_otp",
+ )
+ return None
+
+ if _stopped():
+ return None
+
+ # ------- 步骤9:创建账户 -------
+ time.sleep(random.uniform(0.5, 1.5))
+ emitter.info("正在创建账户信息...", step="create_account")
+ _rand_first = random.choice([
+ "James", "Emma", "Liam", "Olivia", "Noah", "Ava", "Ethan", "Sophia",
+ "Lucas", "Mia", "Mason", "Isabella", "Logan", "Charlotte", "Alexander",
+ "Amelia", "Benjamin", "Harper", "William", "Evelyn", "Henry", "Abigail",
+ ])
+ _rand_last = random.choice([
+ "Smith", "Johnson", "Brown", "Davis", "Wilson", "Moore", "Taylor",
+ "Clark", "Hall", "Young", "Anderson", "Thomas", "Jackson", "White",
+ ])
+ _rand_name = f"{_rand_first} {_rand_last}"
+ _rand_bday = f"{random.randint(1985, 2002)}-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}"
+ _ca_headers = {
+ "referer": "https://auth.openai.com/about-you",
+ "accept": "application/json",
+ "content-type": "application/json",
+ "origin": "https://auth.openai.com",
+ }
+ _ca_headers.update(_trace_headers())
+ create_account_resp = _session_post(
+ "https://auth.openai.com/api/accounts/create_account",
+ headers=_ca_headers,
+ json={"name": _rand_name, "birthdate": _rand_bday},
+ )
+ create_account_status = create_account_resp.status_code
+ emitter.info(f"账户创建状态: {create_account_status}", step="create_account")
+
+ if create_account_status != 200:
+ emitter.error(create_account_resp.text, step="create_account")
+ return None
+
+ emitter.success("账户创建成功!", step="create_account")
+
+ # 跟随 callback URL 完成注册流程
+ try:
+ _ca_data = create_account_resp.json() if create_account_resp.text else {}
+ except Exception:
+ _ca_data = {}
+ _callback_url = (
+ _ca_data.get("continue_url")
+ or _ca_data.get("url")
+ or _ca_data.get("redirect_url")
+ or ""
+ )
+ if _callback_url:
+ emitter.info("正在完成注册回调...", step="create_account")
+ _session_get(
+ _callback_url,
+ headers={
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Upgrade-Insecure-Requests": "1",
+ },
+ timeout=20,
+ )
+
+ if _stopped():
+ return None
+
+ # ------- 步骤10+11:完整 OAuth 登录流程获取 Token -------
+ emitter.info("正在通过 OAuth 登录获取 Token...", step="get_token")
+
+ # 确保 auth 域也有 oai-did cookie
+ try:
+ s.cookies.set("oai-did", did, domain=".auth.openai.com")
+ s.cookies.set("oai-did", did, domain="auth.openai.com")
+ except Exception:
+ pass
+
+ # 10a: 生成 PKCE 参数和 authorize URL
+ oauth = generate_oauth_url()
+
+ # 10b: Sentinel PoW token 生成器(纯 Python)
+ class _SentinelGen:
+ MAX_ATTEMPTS = 500000
+ ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D"
+ def __init__(self, dev_id, ua):
+ self.dev_id = dev_id
+ self.ua = ua
+ self.req_seed = str(random.random())
+ self.sid = str(uuid.uuid4())
+ @staticmethod
+ def _fnv1a(text):
+ h = 2166136261
+ for ch in text:
+ h ^= ord(ch); h = (h * 16777619) & 0xFFFFFFFF
+ h ^= (h >> 16); h = (h * 2246822507) & 0xFFFFFFFF
+ h ^= (h >> 13); h = (h * 3266489909) & 0xFFFFFFFF
+ h ^= (h >> 16); return format(h & 0xFFFFFFFF, "08x")
+ def _cfg(self):
+ now_s = time.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)", time.gmtime())
+ perf = random.uniform(1000, 50000)
+ return ["1920x1080", now_s, 4294705152, random.random(), self.ua,
+ "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js",
+ None, None, "en-US", "en-US,en", random.random(),
+ random.choice(["vendorSub","productSub","hardwareConcurrency","cookieEnabled"]) + "-undefined",
+ random.choice(["location","URL","compatMode"]),
+ random.choice(["Object","Function","Array","Number"]),
+ perf, self.sid, "", random.choice([4,8,12,16]), time.time()*1000 - perf]
+ @staticmethod
+ def _b64(data):
+ return base64.b64encode(json.dumps(data, separators=(",",":")).encode()).decode()
+ def _solve(self, seed, diff, cfg, nonce):
+ cfg[3] = nonce; cfg[9] = round((time.time() - self._t0) * 1000)
+ d = self._b64(cfg); h = self._fnv1a(seed + d)
+ return (d + "~S") if h[:len(diff)] <= diff else None
+ def gen_token(self, seed=None, diff="0"):
+ seed = seed or self.req_seed; self._t0 = time.time(); cfg = self._cfg()
+ for i in range(self.MAX_ATTEMPTS):
+ r = self._solve(seed, str(diff), cfg, i)
+ if r: return "gAAAAAB" + r
+ return "gAAAAAB" + self.ERROR_PREFIX + self._b64(str(None))
+ def gen_req_token(self):
+ cfg = self._cfg(); cfg[3] = 1; cfg[9] = round(random.uniform(5, 50))
+ return "gAAAAAC" + self._b64(cfg)
+
+ _sentinel = _SentinelGen(did, _chrome_ua)
+
+ def _build_sentinel(flow):
+ req_body = json.dumps({"p": _sentinel.gen_req_token(), "id": did, "flow": flow})
+ sen_resp = _session_post(
+ "https://sentinel.openai.com/backend-api/sentinel/req",
+ headers={
+ "Content-Type": "text/plain;charset=UTF-8",
+ "Origin": "https://sentinel.openai.com",
+ "Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html",
+ },
+ data=req_body,
+ )
+ if sen_resp.status_code != 200:
+ return None
+ try:
+ ch = sen_resp.json()
+ except Exception:
+ return None
+ c_val = ch.get("token", "")
+ if not c_val:
+ return None
+ pow_d = ch.get("proofofwork") or {}
+ if pow_d.get("required") and pow_d.get("seed"):
+ p_val = _sentinel.gen_token(seed=pow_d["seed"], diff=pow_d.get("difficulty", "0"))
+ else:
+ p_val = _sentinel.gen_req_token()
+ return json.dumps({"p": p_val, "t": "", "c": c_val, "id": did, "flow": flow}, separators=(",",":"))
+
+ def _oauth_headers(referer):
+ h = {"Accept": "application/json", "Content-Type": "application/json",
+ "Origin": "https://auth.openai.com", "Referer": referer, "oai-device-id": did}
+ h.update(_trace_headers())
+ return h
+
+ # 10c: GET /oauth/authorize — 建立 OAuth 会话
+ emitter.info("OAuth 1/5: 初始化授权...", step="get_token")
+ _session_get(oauth.auth_url, timeout=30)
+
+ if _stopped():
+ return None
+
+ # 10d: POST authorize/continue — 提交邮箱
+ emitter.info("OAuth 2/5: 提交邮箱...", step="get_token")
+ _sen_ac = _build_sentinel("authorize_continue")
+ if not _sen_ac:
+ emitter.error("Sentinel token (authorize_continue) 获取失败", step="get_token")
+ return None
+ _ac_headers = _oauth_headers("https://auth.openai.com/log-in")
+ _ac_headers["openai-sentinel-token"] = _sen_ac
+ _ac_resp = _session_post(
+ "https://auth.openai.com/api/accounts/authorize/continue",
+ headers=_ac_headers,
+ json={"username": {"kind": "email", "value": email}},
+ )
+ emitter.info(f"authorize/continue -> {_ac_resp.status_code}", step="get_token")
+ if _ac_resp.status_code != 200:
+ emitter.error(f"authorize/continue 失败: {str(_ac_resp.text or '')[:200]}", step="get_token")
+ return None
+
+ if _stopped():
+ return None
+
+ # 10e: POST password/verify — 提交密码
+ emitter.info("OAuth 3/5: 验证密码...", step="get_token")
+ _sen_pw = _build_sentinel("password_verify")
+ if not _sen_pw:
+ emitter.error("Sentinel token (password_verify) 获取失败", step="get_token")
+ return None
+ _pw_headers = _oauth_headers("https://auth.openai.com/log-in/password")
+ _pw_headers["openai-sentinel-token"] = _sen_pw
+ _pw_resp = _session_post(
+ "https://auth.openai.com/api/accounts/password/verify",
+ headers=_pw_headers,
+ json={"password": account_password},
+ )
+ emitter.info(f"password/verify -> {_pw_resp.status_code}", step="get_token")
+ if _pw_resp.status_code != 200:
+ emitter.error(f"password/verify 失败: {str(_pw_resp.text or '')[:200]}", step="get_token")
+ return None
+
+ try:
+ _pw_data = _pw_resp.json()
+ except Exception:
+ _pw_data = {}
+ _consent_url = str(_pw_data.get("continue_url") or "").strip()
+ _page_type = str((_pw_data.get("page") or {}).get("type", "")).strip()
+ emitter.info(f"password/verify page={_page_type or '-'} next={(_consent_url or '-')[:140]}", step="get_token")
+
+ # OAuth 阶段可能需要第二次邮箱 OTP 验证
+ _need_oauth_otp = (
+ _page_type == "email_otp_verification"
+ or "email-verification" in (_consent_url or "")
+ or "email-otp" in (_consent_url or "")
+ )
+ if _need_oauth_otp:
+ emitter.info("OAuth 需要邮箱 OTP 验证...", step="get_token")
+ if not dev_token or mail_provider is None:
+ emitter.error("OAuth OTP 验证需要邮箱 token,但不可用", step="get_token")
+ return None
+
+ _otp_ok = False
+ _otp_deadline = time.time() + 120
+ _tried_codes: set = set()
+ while time.time() < _otp_deadline and not _otp_ok:
+ if _stopped():
+ return None
+ try:
+ _otp_code2 = mail_provider.wait_for_otp(
+ dev_token, email, proxy=static_proxy,
+ proxy_selector=mail_proxy_selector, stop_event=stop_event,
+ )
+ except TypeError:
+ _otp_code2 = mail_provider.wait_for_otp(
+ dev_token, email, proxy=static_proxy, stop_event=stop_event,
+ )
+ if not _otp_code2 or _otp_code2 in _tried_codes:
+ time.sleep(2)
+ continue
+ _tried_codes.add(_otp_code2)
+ emitter.info(f"OAuth OTP 尝试: {_otp_code2}", step="get_token")
+ _otp2_h = _oauth_headers("https://auth.openai.com/email-verification")
+ _otp2_resp = _session_post(
+ "https://auth.openai.com/api/accounts/email-otp/validate",
+ headers=_otp2_h,
+ json={"code": _otp_code2},
+ )
+ emitter.info(f"OAuth OTP validate -> {_otp2_resp.status_code}", step="get_token")
+ if _otp2_resp.status_code == 200:
+ try:
+ _otp2_data = _otp2_resp.json()
+ except Exception:
+ _otp2_data = {}
+ _consent_url = str(_otp2_data.get("continue_url") or "").strip() or _consent_url
+ _page_type = str((_otp2_data.get("page") or {}).get("type", "")).strip() or _page_type
+ emitter.info(f"OAuth OTP 验证通过 page={_page_type or '-'} next={(_consent_url or '-')[:140]}", step="get_token")
+ _otp_ok = True
+ break
+ time.sleep(2)
+
+ if not _otp_ok:
+ emitter.error(f"OAuth OTP 验证失败,已尝试 {len(_tried_codes)} 个验证码", step="get_token")
+ return None
+
+ if _stopped():
+ return None
+
+ # 10f: Workspace/consent/org 处理 + 提取 code(对标参考代码)
+ _AUTH = "https://auth.openai.com"
+
+ def _extract_code(url):
+ if not url or "code=" not in url:
+ return None
+ try:
+ return urllib.parse.parse_qs(urllib.parse.urlparse(url).query).get("code", [None])[0]
+ except Exception:
+ return None
+
+ def _follow_for_code(start_url):
+ """手动跟随重定向链,逐步检查 Location 中是否含 code"""
+ url = start_url
+ for _ in range(12):
+ try:
+ r = _session_get(url, allow_redirects=False, timeout=15)
+ except Exception as e:
+ # curl_cffi 连接 localhost 会抛异常,URL 可能在异常信息中
+ m = re.search(r'(https?://localhost[^\s\'"]+)', str(e))
+ if m:
+ return _extract_code(m.group(1))
+ return None
+ if r.status_code in (301, 302, 303, 307, 308):
+ loc = r.headers.get("Location", "")
+ if not loc:
+ break
+ next_url = urllib.parse.urljoin(url, loc)
+ c = _extract_code(next_url)
+ if c:
+ return c
+ url = next_url
+ continue
+ break
+ return None
+
+ def _ws_org_select(consent_ref):
+ """完整的 workspace + organization 选择流程"""
+ # 解析 session cookie 获取 workspace
+ _ck_data = None
+ _auth_ck = s.cookies.get("oai-client-auth-session") or relay_cookie_jar.get("oai-client-auth-session") or ""
+ if _auth_ck:
+ try:
+ _ck_data = _decode_jwt_segment(_auth_ck.split(".")[0])
+ except Exception:
+ pass
+ if not _ck_data:
+ emitter.info("无法解码 auth session cookie", step="workspace")
+ return None
+
+ _ws_list = _ck_data.get("workspaces") or []
+ _ws_id = str((_ws_list[0] or {}).get("id") or "").strip() if _ws_list else ""
+ if not _ws_id:
+ emitter.info("session 中没有 workspace", step="workspace")
+ return None
+
+ emitter.info(f"选择 workspace: {_ws_id}", step="workspace")
+ _ws_h = _oauth_headers(consent_ref)
+ _ws_resp = _session_post(
+ f"{_AUTH}/api/accounts/workspace/select",
+ headers=_ws_h,
+ json={"workspace_id": _ws_id},
+ allow_redirects=False,
+ )
+ emitter.info(f"workspace/select -> {_ws_resp.status_code}", step="workspace")
+
+ # 如果是重定向,直接提取 code
+ if _ws_resp.status_code in (301, 302, 303, 307, 308):
+ loc = _ws_resp.headers.get("Location", "")
+ if loc.startswith("/"):
+ loc = f"{_AUTH}{loc}"
+ c = _extract_code(loc)
+ if c:
+ return c
+ return _follow_for_code(loc)
+
+ if _ws_resp.status_code != 200:
+ return None
+
+ try:
+ _ws_data = _ws_resp.json()
+ except Exception:
+ return None
+
+ _ws_next = str(_ws_data.get("continue_url") or "").strip()
+ _ws_page = str((_ws_data.get("page") or {}).get("type", ""))
+ _orgs = (_ws_data.get("data") or {}).get("orgs") or []
+ emitter.info(f"workspace/select page={_ws_page or '-'} orgs={len(_orgs)} next={(_ws_next or '-')[:140]}", step="workspace")
+
+ # Organization 选择
+ if _orgs:
+ _org_id = (_orgs[0] or {}).get("id")
+ _projects = (_orgs[0] or {}).get("projects") or []
+ _proj_id = (_projects[0] or {}).get("id") if _projects else None
+ if _org_id:
+ _org_body = {"org_id": _org_id}
+ if _proj_id:
+ _org_body["project_id"] = _proj_id
+ _org_ref = _ws_next if _ws_next and _ws_next.startswith("http") else f"{_AUTH}{_ws_next}" if _ws_next else consent_ref
+ _org_h = _oauth_headers(_org_ref)
+ emitter.info(f"选择 organization: {_org_id}", step="workspace")
+ _org_resp = _session_post(
+ f"{_AUTH}/api/accounts/organization/select",
+ headers=_org_h,
+ json=_org_body,
+ allow_redirects=False,
+ )
+ emitter.info(f"organization/select -> {_org_resp.status_code}", step="workspace")
+
+ if _org_resp.status_code in (301, 302, 303, 307, 308):
+ loc = _org_resp.headers.get("Location", "")
+ if loc.startswith("/"):
+ loc = f"{_AUTH}{loc}"
+ c = _extract_code(loc)
+ if c:
+ return c
+ return _follow_for_code(loc)
+
+ if _org_resp.status_code == 200:
+ try:
+ _org_data = _org_resp.json()
+ except Exception:
+ _org_data = {}
+ _org_next = str(_org_data.get("continue_url") or "").strip()
+ if _org_next:
+ if _org_next.startswith("/"):
+ _org_next = f"{_AUTH}{_org_next}"
+ c = _extract_code(_org_next)
+ if c:
+ return c
+ return _follow_for_code(_org_next)
+
+ # 无 org 或 org 选择后仍无 code,跟随 ws_next
+ if _ws_next:
+ if _ws_next.startswith("/"):
+ _ws_next = f"{_AUTH}{_ws_next}"
+ c = _extract_code(_ws_next)
+ if c:
+ return c
+ return _follow_for_code(_ws_next)
+
+ return None
+
+ _code = None
+
+ # 规范化 consent_url
+ if _consent_url and _consent_url.startswith("/"):
+ _consent_url = f"{_AUTH}{_consent_url}"
+ if not _consent_url and "consent" in _page_type:
+ _consent_url = f"{_AUTH}/sign-in-with-chatgpt/codex/consent"
+
+ # 先从 URL 直接提取
+ if _consent_url:
+ _code = _extract_code(_consent_url)
+
+ # 跟随 consent_url 重定向
+ if not _code and _consent_url:
+ emitter.info("OAuth 4/5: 跟随 consent URL...", step="get_token")
+ _code = _follow_for_code(_consent_url)
+
+ # workspace + organization 选择
+ _consent_hint = any(kw in (_consent_url or "") for kw in ["consent", "workspace", "organization", "sign-in-with"])
+ _consent_hint = _consent_hint or any(kw in _page_type for kw in ["consent", "organization"])
+ if not _code and (_consent_hint or not _consent_url):
+ emitter.info("OAuth 4/5: 处理 workspace/org...", step="workspace")
+ _ws_ref = _consent_url or f"{_AUTH}/sign-in-with-chatgpt/codex/consent"
+ _code = _ws_org_select(_ws_ref)
+
+ # 回退
+ if not _code:
+ emitter.info("OAuth 4/5: 回退 consent 路径...", step="get_token")
+ _code = _ws_org_select(f"{_AUTH}/sign-in-with-chatgpt/codex/consent")
+ if not _code:
+ _code = _follow_for_code(f"{_AUTH}/sign-in-with-chatgpt/codex/consent")
+
+ if not _code:
+ emitter.error("未能获取 OAuth authorization code", step="get_token")
+ try: s.close()
+ except: pass
+ return None
+
+ # 10g: POST /oauth/token — 用 code 换取 Token
+ emitter.info("OAuth 5/5: 交换 Token...", step="get_token")
+ _token_resp = _session_post(
+ TOKEN_URL,
+ headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
+ data=urllib.parse.urlencode({
+ "grant_type": "authorization_code",
+ "code": _code,
+ "redirect_uri": oauth.redirect_uri,
+ "client_id": CLIENT_ID,
+ "code_verifier": oauth.code_verifier,
+ }),
+ timeout=30,
+ )
+ if _token_resp.status_code != 200:
+ emitter.error(f"Token 交换失败({_token_resp.status_code}): {str(_token_resp.text or '')[:200]}", step="get_token")
+ try: s.close()
+ except: pass
+ return None
+
+ try:
+ _token_json = _token_resp.json()
+ except Exception:
+ _token_json = json.loads(str(_token_resp.text or "{}"))
+
+ emitter.success("Token 获取成功!", step="get_token")
+ try: s.close()
+ except: pass
+ return _build_token_result(_token_json, account_password=account_password)
+
+ except Exception as e:
+ emitter.error(f"运行时发生错误: {e}", step="runtime")
+ try: s.close()
+ except: pass
+ return None
+
+# ==========================================
+# CLI 入口(兼容直接运行)
+# ==========================================
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="OpenAI 账号池编排器脚本")
+ parser.add_argument(
+ "--proxy", default=None, help="代理地址,如 http://127.0.0.1:7897"
+ )
+ parser.add_argument("--once", action="store_true", help="只运行一次")
+ parser.add_argument("--sleep-min", type=int, default=5, help="循环模式最短等待秒数")
+ parser.add_argument(
+ "--sleep-max", type=int, default=30, help="循环模式最长等待秒数"
+ )
+ args = parser.parse_args()
+
+ sleep_min = max(1, args.sleep_min)
+ sleep_max = max(sleep_min, args.sleep_max)
+
+ os.makedirs(TOKENS_DIR, exist_ok=True)
+
+ try:
+ config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "sync_config.json")
+ with open(config_path, "r", encoding="utf-8") as f:
+ sync_cfg = json.load(f)
+ except Exception:
+ sync_cfg = {}
+
+ cpa_base_url = str(sync_cfg.get("cpa_base_url") or "").strip()
+ cpa_token = str(sync_cfg.get("cpa_token") or "").strip()
+
+ pool_maintainer = None
+ if cpa_base_url and cpa_token:
+ try:
+ from .pool_maintainer import PoolMaintainer
+ pool_maintainer = PoolMaintainer(
+ cpa_base_url=cpa_base_url,
+ cpa_token=cpa_token,
+ )
+ except Exception as e:
+ print(f"[-] 初始化 PoolMaintainer 失败: {e}")
+
+ count = 0
+ print("[Info] OpenAI 账号池编排器 - CLI 模式")
+
+ while True:
+ count += 1
+ print(
+ f"\n[{datetime.now().strftime('%H:%M:%S')}] >>> 开始第 {count} 次注册流程 <<<"
+ )
+
+ try:
+ token_json = run(args.proxy)
+
+ if token_json:
+ try:
+ t_data = json.loads(token_json)
+ fname_email = t_data.get("email", "unknown").replace("@", "_")
+ except Exception:
+ fname_email = "unknown"
+ t_data = {}
+
+ file_name = f"token_{fname_email}_{time.time_ns()}.json"
+ file_path = os.path.join(TOKENS_DIR, file_name)
+
+ _write_text_atomic(file_path, token_json)
+
+ print(f"[*] 成功! Token 已保存至: {file_path}")
+
+ if pool_maintainer and t_data:
+ print(f"[*] 正在尝试上传到 CPA...")
+ try:
+ cpa_ok = pool_maintainer.upload_token(file_name, t_data, proxy=args.proxy or "")
+ upload_email = t_data.get('email', fname_email)
+ if cpa_ok:
+ print(f"[+] CPA 上传成功: {upload_email}")
+ else:
+ print(f"[-] CPA 上传失败: {upload_email}")
+ except Exception as e:
+ print(f"[-] CPA 上传抛出异常: {e}")
+ else:
+ print("[-] 本次注册失败。")
+
+ except Exception as e:
+ print(f"[Error] 发生未捕获异常: {e}")
+
+ if args.once:
+ break
+
+ wait_time = random.randint(sleep_min, sleep_max)
+ print(f"[*] 休息 {wait_time} 秒...")
+ time.sleep(wait_time)
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/openai_pool_orchestrator/server.py b/openai_pool_orchestrator/server.py
new file mode 100755
index 0000000..c2ea221
--- /dev/null
+++ b/openai_pool_orchestrator/server.py
@@ -0,0 +1,3556 @@
+"""
+FastAPI 后端服务
+提供 REST API + SSE 实时日志推送
+"""
+
+import asyncio
+import copy
+import json
+import re
+import os
+import queue
+import random
+import threading
+import tempfile
+import time
+import urllib.request
+import urllib.error
+import uuid
+from datetime import datetime
+from pathlib import Path
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import uvicorn
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.concurrency import run_in_threadpool
+from fastapi.responses import HTMLResponse, StreamingResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel, Field
+
+from . import __version__, TOKENS_DIR, CONFIG_FILE, STATE_FILE, STATIC_DIR, DATA_DIR
+from .register import EventEmitter, run, _fetch_proxy_from_pool
+from .mail_providers import create_provider, MultiMailRouter
+from .pool_maintainer import PoolMaintainer, Sub2ApiMaintainer
+
+# ==========================================
+# 同步配置(内存持久化到 data/sync_config.json)
+# ==========================================
+
+# CONFIG_FILE 和 TOKENS_DIR 已从包 __init__.py 导入
+
+
+_config_lock = threading.RLock()
+_service_shutdown_event = threading.Event()
+_sub2api_accounts_cache_lock = threading.Lock()
+_sub2api_accounts_cache: Dict[str, Any] = {
+ "signature": "",
+ "ts": 0.0,
+ "inventory": None,
+}
+
+SUB2API_MAINTAIN_ACTION_DEFAULTS: Dict[str, bool] = {
+ "refresh_abnormal_accounts": True,
+ "delete_abnormal_accounts": True,
+ "dedupe_duplicate_accounts": True,
+}
+
+
+def _as_bool(value: Any, default: bool = False) -> bool:
+ if isinstance(value, bool):
+ return value
+ if value is None:
+ return default
+ if isinstance(value, (int, float)):
+ return bool(value)
+ text = str(value).strip().lower()
+ if text in ("1", "true", "yes", "on"):
+ return True
+ if text in ("0", "false", "no", "off", ""):
+ return False
+ return default
+
+
+def _normalize_sub2api_maintain_actions(raw: Any) -> Dict[str, bool]:
+ source = raw if isinstance(raw, dict) else {}
+ return {
+ key: _as_bool(source.get(key, default), default=default)
+ for key, default in SUB2API_MAINTAIN_ACTION_DEFAULTS.items()
+ }
+
+
+def _get_sub2api_maintain_actions(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
+ config = cfg if cfg is not None else _get_sync_config()
+ return _normalize_sub2api_maintain_actions(config.get("sub2api_maintain_actions"))
+
+
+def _describe_sub2api_maintain_actions(actions: Optional[Dict[str, bool]] = None) -> str:
+ normalized = _normalize_sub2api_maintain_actions(actions)
+ labels: List[str] = []
+ if normalized["refresh_abnormal_accounts"]:
+ labels.append("异常测活")
+ if normalized["delete_abnormal_accounts"]:
+ labels.append("异常清理")
+ if normalized["dedupe_duplicate_accounts"]:
+ labels.append("重复清理")
+ return "、".join(labels) if labels else "无动作"
+
+
+def _format_sub2api_maintain_result_message(result: Dict[str, Any], *, auto: bool = False) -> str:
+ prefix = "自动维护" if auto else "维护完成"
+ actions_text = _describe_sub2api_maintain_actions(result.get("actions"))
+ return (
+ f"[Sub2Api] {prefix}({actions_text}): 异常 {result.get('error_count', 0)}, "
+ f"刷新恢复 {result.get('refreshed', 0)}, "
+ f"重复组 {result.get('duplicate_groups', 0)}, "
+ f"删除 {result.get('deleted_ok', 0)}(失败 {result.get('deleted_fail', 0)}), "
+ f"耗时 {round((result.get('duration_ms', 0) or 0) / 1000, 2)}s"
+ )
+
+
+def _clear_sub2api_accounts_cache() -> None:
+ with _sub2api_accounts_cache_lock:
+ _sub2api_accounts_cache["signature"] = ""
+ _sub2api_accounts_cache["ts"] = 0.0
+ _sub2api_accounts_cache["inventory"] = None
+
+
+def _build_sub2api_accounts_cache_signature(cfg: Optional[Dict[str, Any]] = None) -> str:
+ config = cfg or _get_sync_config()
+ signature_payload = {
+ "base_url": str(config.get("base_url", "") or "").strip(),
+ "email": str(config.get("email", "") or "").strip().lower(),
+ "sub2api_min_candidates": int(config.get("sub2api_min_candidates", 200) or 200),
+ }
+ return json.dumps(signature_payload, ensure_ascii=False, sort_keys=True)
+
+
+def _get_sub2api_accounts_inventory_snapshot(
+ sm: Sub2ApiMaintainer,
+ cfg: Optional[Dict[str, Any]] = None,
+ *,
+ timeout: int = 15,
+ ttl_seconds: int = 12,
+) -> Dict[str, Any]:
+ signature = _build_sub2api_accounts_cache_signature(cfg)
+ now = time.time()
+ with _sub2api_accounts_cache_lock:
+ cached_signature = str(_sub2api_accounts_cache.get("signature") or "")
+ cached_ts = float(_sub2api_accounts_cache.get("ts") or 0.0)
+ cached_inventory = _sub2api_accounts_cache.get("inventory")
+ if (
+ cached_signature == signature
+ and isinstance(cached_inventory, dict)
+ and (now - cached_ts) <= ttl_seconds
+ ):
+ return copy.deepcopy(cached_inventory)
+
+ inventory = sm.list_account_inventory(timeout=timeout)
+ with _sub2api_accounts_cache_lock:
+ _sub2api_accounts_cache["signature"] = signature
+ _sub2api_accounts_cache["ts"] = now
+ _sub2api_accounts_cache["inventory"] = copy.deepcopy(inventory)
+ return inventory
+
+
+def _filter_sub2api_account_items(items: List[Dict[str, Any]], status: str = "all", keyword: str = "") -> List[Dict[str, Any]]:
+ normalized_status = str(status or "all").strip().lower() or "all"
+ keyword_norm = str(keyword or "").strip().lower()
+ abnormal_statuses = {"error", "disabled"}
+ filtered: List[Dict[str, Any]] = []
+
+ for item in items:
+ item_status = str(item.get("status") or "").strip().lower()
+ is_abnormal = item_status in abnormal_statuses
+ is_duplicate = bool(item.get("is_duplicate"))
+
+ if normalized_status == "normal" and is_abnormal:
+ continue
+ if normalized_status == "abnormal" and not is_abnormal:
+ continue
+ if normalized_status == "error" and item_status != "error":
+ continue
+ if normalized_status == "disabled" and item_status != "disabled":
+ continue
+ if normalized_status == "duplicate" and not is_duplicate:
+ continue
+
+ if keyword_norm:
+ email = str(item.get("email") or "").lower()
+ name = str(item.get("name") or "").lower()
+ account_id = str(item.get("id") or "").lower()
+ if keyword_norm not in email and keyword_norm not in name and keyword_norm not in account_id:
+ continue
+
+ filtered.append(item)
+
+ return filtered
+
+
+def _paginate_sub2api_account_items(
+ items: List[Dict[str, Any]], page: int = 1, page_size: int = 20,
+) -> Dict[str, Any]:
+ safe_page_size = max(10, min(int(page_size or 20), 100))
+ total = len(items)
+ total_pages = max(1, (total + safe_page_size - 1) // safe_page_size)
+ safe_page = max(1, min(int(page or 1), total_pages))
+ start = (safe_page - 1) * safe_page_size
+ end = start + safe_page_size
+ return {
+ "items": items[start:end],
+ "page": safe_page,
+ "page_size": safe_page_size,
+ "filtered_total": total,
+ "total_pages": total_pages,
+ }
+
+
+def _write_json_atomic(path: Path, payload: Dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_path = tempfile.mkstemp(prefix=f".{path.stem}_", suffix=path.suffix, dir=str(path.parent))
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(tmp_path, path)
+ finally:
+ try:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+ except OSError:
+ pass
+
+
+def _load_sync_config() -> Dict[str, Any]:
+ if CONFIG_FILE.exists():
+ try:
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
+ except Exception:
+ pass
+ return {
+ "base_url": "", "bearer_token": "", "account_name": "AutoReg", "auto_sync": False,
+ "cpa_base_url": "", "cpa_token": "", "min_candidates": 800,
+ "used_percent_threshold": 95, "auto_maintain": False, "maintain_interval_minutes": 30,
+ "upload_mode": "snapshot",
+ "mail_provider": "mailtm",
+ "mail_config": {"api_base": "https://api.mail.tm", "api_key": "", "bearer_token": ""},
+ "sub2api_min_candidates": 200,
+ "sub2api_auto_maintain": False,
+ "sub2api_maintain_interval_minutes": 30,
+ "sub2api_maintain_actions": copy.deepcopy(SUB2API_MAINTAIN_ACTION_DEFAULTS),
+ "proxy": "",
+ "auto_register": False,
+ "proxy_pool_enabled": True,
+ "proxy_pool_api_url": "https://zenproxy.top/api/fetch",
+ "proxy_pool_auth_mode": "query",
+ "proxy_pool_api_key": "19c0ec43-8f76-4c97-81bc-bcda059eeba4",
+ "proxy_pool_count": 1,
+ "proxy_pool_country": "US",
+ }
+
+
+def _normalize_config(cfg: Dict[str, Any]) -> Dict[str, Any]:
+ """将旧的单邮箱提供商配置迁移到多提供商格式,含类型校验"""
+ cfg = copy.deepcopy(cfg or {})
+ legacy = str(cfg.get("mail_provider", "mailtm") or "mailtm").strip().lower()
+ legacy_cfg = cfg.get("mail_config") or {}
+ if not isinstance(legacy_cfg, dict):
+ legacy_cfg = {}
+
+ raw_providers = cfg.get("mail_providers")
+ providers = raw_providers if isinstance(raw_providers, list) else []
+ providers = [str(n).strip().lower() for n in providers if str(n).strip()]
+ if not providers:
+ providers = [legacy]
+
+ raw_cfgs = cfg.get("mail_provider_configs")
+ provider_cfgs = raw_cfgs if isinstance(raw_cfgs, dict) else {}
+ for name in providers:
+ if name not in provider_cfgs or not isinstance(provider_cfgs.get(name), dict):
+ provider_cfgs[name] = {}
+ if legacy in provider_cfgs:
+ for k, v in legacy_cfg.items():
+ provider_cfgs[legacy].setdefault(k, v)
+
+ strategy = str(cfg.get("mail_strategy", "round_robin") or "round_robin").strip().lower()
+ if strategy not in ("round_robin", "random", "failover"):
+ strategy = "round_robin"
+
+ cfg["mail_providers"] = providers
+ cfg["mail_provider_configs"] = provider_cfgs
+ cfg["mail_strategy"] = strategy
+ cfg["mail_provider"] = providers[0]
+ upload_mode = str(cfg.get("upload_mode", "snapshot") or "snapshot").strip().lower()
+ if upload_mode not in ("snapshot", "decoupled"):
+ upload_mode = "snapshot"
+ cfg["upload_mode"] = upload_mode
+ cfg["auto_sync"] = _as_bool(cfg.get("auto_sync", False), default=False)
+ cfg["auto_maintain"] = _as_bool(cfg.get("auto_maintain", False), default=False)
+ cfg["sub2api_auto_maintain"] = _as_bool(cfg.get("sub2api_auto_maintain", False), default=False)
+ cfg["sub2api_maintain_actions"] = _normalize_sub2api_maintain_actions(cfg.get("sub2api_maintain_actions"))
+ cfg["multithread"] = _as_bool(cfg.get("multithread", False), default=False)
+ cfg["auto_register"] = _as_bool(cfg.get("auto_register", False), default=False)
+ try:
+ cfg["thread_count"] = max(1, min(int(cfg.get("thread_count", 3)), 10))
+ except (ValueError, TypeError):
+ cfg["thread_count"] = 3
+ cfg["proxy_pool_enabled"] = _as_bool(cfg.get("proxy_pool_enabled", True), default=True)
+ proxy_pool_api_url = str(cfg.get("proxy_pool_api_url", "https://zenproxy.top/api/fetch") or "").strip()
+ cfg["proxy_pool_api_url"] = proxy_pool_api_url or "https://zenproxy.top/api/fetch"
+ proxy_pool_auth_mode = str(cfg.get("proxy_pool_auth_mode", "query") or "").strip().lower()
+ if proxy_pool_auth_mode not in ("header", "query"):
+ proxy_pool_auth_mode = "query"
+ cfg["proxy_pool_auth_mode"] = proxy_pool_auth_mode
+ cfg["proxy_pool_api_key"] = str(cfg.get("proxy_pool_api_key", "19c0ec43-8f76-4c97-81bc-bcda059eeba4") or "").strip()
+ try:
+ cfg["proxy_pool_count"] = max(1, min(int(cfg.get("proxy_pool_count", 1)), 20))
+ except (TypeError, ValueError):
+ cfg["proxy_pool_count"] = 1
+ cfg["proxy_pool_country"] = str(cfg.get("proxy_pool_country", "US") or "US").strip().upper() or "US"
+ return cfg
+
+
+def _pool_relay_url_from_fetch_url(api_url: str) -> str:
+ raw = str(api_url or "").strip()
+ if not raw:
+ return ""
+ if "://" not in raw:
+ raw = "https://" + raw
+ try:
+ from urllib.parse import urlparse
+ parsed = urlparse(raw)
+ scheme = parsed.scheme or "https"
+ netloc = parsed.netloc
+ if not netloc:
+ return ""
+ return f"{scheme}://{netloc}/api/relay"
+ except Exception:
+ return ""
+
+
+def _get_sync_config() -> Dict[str, Any]:
+ with _config_lock:
+ return copy.deepcopy(_sync_config)
+
+
+def _set_sync_config(cfg: Dict[str, Any]) -> Dict[str, Any]:
+ global _sync_config
+ normalized = _normalize_config(cfg)
+ with _config_lock:
+ _write_json_atomic(CONFIG_FILE, normalized)
+ _sync_config = normalized
+ return copy.deepcopy(_sync_config)
+
+
+def _save_sync_config(cfg: Dict[str, Any]) -> Dict[str, Any]:
+ return _set_sync_config(cfg)
+
+
+_sync_config = _normalize_config(_load_sync_config())
+
+
+def _is_auto_sync_enabled(cfg: Optional[Dict[str, Any]] = None) -> bool:
+ config = cfg if cfg is not None else _get_sync_config()
+ return _as_bool(config.get("auto_sync", False), default=False)
+
+
+def _push_refresh_token(base_url: str, bearer: str, refresh_token: str) -> Dict[str, Any]:
+ """
+ 调用 Sub2Api 平台 API 提交单个 refresh_token。
+ 返回 {ok: bool, status: int, body: str}
+ """
+ url = base_url.rstrip("/") + "/api/v1/admin/openai/refresh-token"
+ payload = json.dumps({"refresh_token": refresh_token}).encode("utf-8")
+ req = urllib.request.Request(
+ url,
+ data=payload,
+ method="POST",
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {bearer}",
+ "Accept": "application/json",
+ },
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=20) as resp:
+ body = resp.read().decode("utf-8", "replace")
+ return {"ok": True, "status": resp.status, "body": body}
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", "replace")
+ return {"ok": False, "status": exc.code, "body": body}
+ except Exception as e:
+ return {"ok": False, "status": 0, "body": str(e)}
+
+
+UPLOAD_PLATFORMS = ("cpa", "sub2api")
+
+
+def _extract_uploaded_platforms(token_data: Dict[str, Any]) -> List[str]:
+ platforms = set()
+ raw_platforms = token_data.get("uploaded_platforms")
+ if isinstance(raw_platforms, list):
+ for p in raw_platforms:
+ name = str(p).strip().lower()
+ if name in UPLOAD_PLATFORMS:
+ platforms.add(name)
+ if token_data.get("cpa_uploaded") or token_data.get("cpa_synced"):
+ platforms.add("cpa")
+ if token_data.get("sub2api_uploaded") or token_data.get("sub2api_synced") or token_data.get("synced"):
+ platforms.add("sub2api")
+ return [p for p in UPLOAD_PLATFORMS if p in platforms]
+
+
+def _is_sub2api_uploaded(token_data: Dict[str, Any]) -> bool:
+ return "sub2api" in _extract_uploaded_platforms(token_data)
+
+
+def _mark_token_uploaded_platform(file_path: str, platform: str) -> bool:
+ platform_name = str(platform).strip().lower()
+ if platform_name not in UPLOAD_PLATFORMS:
+ return False
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ token_data = json.load(f)
+ if not isinstance(token_data, dict):
+ return False
+
+ platforms = _extract_uploaded_platforms(token_data)
+ if platform_name not in platforms:
+ platforms.append(platform_name)
+ token_data["uploaded_platforms"] = [p for p in UPLOAD_PLATFORMS if p in set(platforms)]
+ token_data[f"{platform_name}_uploaded"] = True
+ token_data[f"{platform_name}_synced"] = True
+
+ if platform_name == "sub2api":
+ token_data["synced"] = True # 兼容旧前端逻辑
+
+ uploaded_at = token_data.get("uploaded_at")
+ if not isinstance(uploaded_at, dict):
+ uploaded_at = {}
+ uploaded_at[platform_name] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ token_data["uploaded_at"] = uploaded_at
+
+ _write_json_atomic(Path(file_path), token_data)
+ return True
+ except Exception:
+ return False
+
+
+# ==========================================
+# 统计数据持久化
+# ==========================================
+
+# STATE_FILE 已从包 __init__.py 导入
+
+
+def _load_state() -> Dict[str, int]:
+ if STATE_FILE.exists():
+ try:
+ return json.loads(STATE_FILE.read_text(encoding="utf-8"))
+ except Exception:
+ pass
+ return {"success": 0, "fail": 0}
+
+
+def _save_state(success: int, fail: int) -> None:
+ try:
+ _write_json_atomic(STATE_FILE, {"success": success, "fail": fail})
+ except Exception:
+ pass
+
+
+# ==========================================
+# 应用初始化
+# ==========================================
+
+app = FastAPI(title="OpenAI Pool Orchestrator", version=__version__)
+
+# STATIC_DIR 和 TOKENS_DIR 已从包 __init__.py 导入
+STATIC_DIR.mkdir(exist_ok=True)
+os.makedirs(str(TOKENS_DIR), exist_ok=True)
+
+# ==========================================
+# 任务状态管理
+# ==========================================
+
+
+class TaskState:
+ """全局任务状态,支持多 Worker 运行快照与结构化 SSE 事件。"""
+
+ _WORKER_STEP_DEFINITIONS = {
+ "check_proxy": "网络检查",
+ "create_email": "创建邮箱",
+ "oauth_init": "OAuth 初始化",
+ "sentinel": "Sentinel Token",
+ "signup": "提交注册",
+ "send_otp": "发送验证码",
+ "wait_otp": "等待验证码",
+ "verify_otp": "验证 OTP",
+ "create_account": "创建账户",
+ "workspace": "选择 Workspace",
+ "get_token": "获取 Token",
+ "saved": "保存 Token",
+ "cpa_upload": "上传 CPA",
+ "sync": "同步 Sub2Api",
+ "retry": "等待重试",
+ "wait": "等待下一轮",
+ "dedupe": "重复检测",
+ "runtime": "运行异常",
+ "auto_stop": "自动停止",
+ "stopping": "停止中",
+ "stopped": "已停止",
+ "mode": "上传策略",
+ "shutdown": "服务关闭",
+ }
+ _REGISTRATION_STEPS = frozenset({
+ "check_proxy", "create_email", "oauth_init", "sentinel",
+ "signup", "send_otp", "wait_otp", "verify_otp",
+ "create_account", "workspace", "get_token",
+ })
+
+ def __init__(self) -> None:
+ self.status: str = "stopped"
+ self.stop_event = threading.Event()
+ self.thread: Optional[threading.Thread] = None
+ self._worker_threads: Dict[int, threading.Thread] = {}
+ self._task_lock = threading.RLock()
+ self._sse_queues: list[tuple[asyncio.AbstractEventLoop, asyncio.Queue]] = []
+ self._sse_lock = threading.Lock()
+
+ _s = _load_state()
+ self.success_count: int = int(_s.get("success", 0) or 0)
+ self.fail_count: int = int(_s.get("fail", 0) or 0)
+ self.current_proxy: str = ""
+ self.worker_count: int = 0
+ self.upload_mode: str = "snapshot"
+ self.target_count: int = 0
+ self.run_success_count: int = 0
+ self.run_fail_count: int = 0
+ self.platform_success_count: Dict[str, int] = {name: 0 for name in UPLOAD_PLATFORMS}
+ self.platform_fail_count: Dict[str, int] = {name: 0 for name in UPLOAD_PLATFORMS}
+ self.platform_backlog_count: Dict[str, int] = {name: 0 for name in UPLOAD_PLATFORMS}
+ self._upload_queues: Dict[str, queue.Queue] = {}
+
+ self.run_id: Optional[str] = None
+ self.revision: int = 0
+ self.created_at: Optional[str] = None
+ self.started_at: Optional[str] = None
+ self.finished_at: Optional[str] = None
+ self.stop_reason: str = ""
+ self.last_error: str = ""
+ self.completion_semantics: str = "registration_only"
+ self._focus_worker_id: Optional[int] = None
+ self._worker_runtime: Dict[int, Dict[str, Any]] = {}
+
+ def _now_iso(self) -> str:
+ return datetime.now().isoformat(timespec="seconds")
+
+ def _new_run_id(self) -> str:
+ return uuid.uuid4().hex[:12]
+
+ def _next_revision_locked(self) -> int:
+ self.revision += 1
+ return self.revision
+
+ def _completion_semantics_locked(self) -> str:
+ return "requires_postprocess" if _is_auto_sync_enabled() else "registration_only"
+
+ def _empty_worker_runtime_locked(self, worker_id: int, worker_label: Optional[str] = None) -> Dict[str, Any]:
+ return {
+ "worker_id": worker_id,
+ "worker_label": worker_label or f"W{worker_id}",
+ "status": "starting",
+ "phase": "prepare",
+ "attempt": 0,
+ "mail_provider": "",
+ "account_email": "",
+ "current_step": "",
+ "message": "",
+ "updated_at": self._now_iso(),
+ "steps": [],
+ }
+
+ def _empty_runtime_snapshot_locked(self) -> Dict[str, Any]:
+ workers = [
+ copy.deepcopy(runtime)
+ for _, runtime in sorted(self._worker_runtime.items(), key=lambda item: item[0])
+ ]
+ return {
+ "run_id": self.run_id,
+ "revision": self.revision,
+ "completion_semantics": self.completion_semantics,
+ "focus_worker_id": self._focus_worker_id,
+ "aggregate": self._aggregate_runtime_locked(),
+ "workers": workers,
+ }
+
+ def _task_snapshot_locked(self) -> Dict[str, Any]:
+ return {
+ "run_id": self.run_id,
+ "revision": self.revision,
+ "status": self.status,
+ "worker_count": self.worker_count,
+ "upload_mode": self.upload_mode,
+ "completion_semantics": self.completion_semantics,
+ "target_count": self.target_count,
+ "created_at": self.created_at,
+ "started_at": self.started_at,
+ "finished_at": self.finished_at,
+ "stop_reason": self.stop_reason,
+ "last_error": self.last_error,
+ "proxy": self.current_proxy,
+ }
+
+ def _stats_snapshot_locked(self) -> Dict[str, Any]:
+ platform = {}
+ for name in UPLOAD_PLATFORMS:
+ success = int(self.platform_success_count.get(name, 0) or 0)
+ fail = int(self.platform_fail_count.get(name, 0) or 0)
+ backlog = int(self.platform_backlog_count.get(name, 0) or 0)
+ platform[name] = {
+ "success": success,
+ "fail": fail,
+ "backlog": backlog,
+ "total": success + fail,
+ }
+ return {
+ "lifetime": {
+ "success": self.success_count,
+ "fail": self.fail_count,
+ "total": self.success_count + self.fail_count,
+ },
+ "run": {
+ "success": self.run_success_count,
+ "fail": self.run_fail_count,
+ "total": self.run_success_count + self.run_fail_count,
+ },
+ "platform": platform,
+ "success": self.success_count,
+ "fail": self.fail_count,
+ "total": self.success_count + self.fail_count,
+ }
+
+ def _status_snapshot_locked(self) -> Dict[str, Any]:
+ return {
+ "task": self._task_snapshot_locked(),
+ "runtime": self._empty_runtime_snapshot_locked(),
+ "stats": self._stats_snapshot_locked(),
+ "server_time": self._now_iso(),
+ }
+
+ def get_status_snapshot(self) -> Dict[str, Any]:
+ with self._task_lock:
+ return self._status_snapshot_locked()
+
+ def subscribe(self) -> asyncio.Queue:
+ loop = asyncio.get_running_loop()
+ q: asyncio.Queue = asyncio.Queue(maxsize=500)
+ with self._sse_lock:
+ self._sse_queues.append((loop, q))
+ return q
+
+ def unsubscribe(self, q: asyncio.Queue) -> None:
+ with self._sse_lock:
+ self._sse_queues = [(loop, queue_obj) for loop, queue_obj in self._sse_queues if queue_obj is not q]
+
+ def _enqueue_sse_payload(self, payload: Dict[str, Any]) -> None:
+ with self._sse_lock:
+ subscribers = list(self._sse_queues)
+ for loop, q in subscribers:
+ def _enqueue(target_q: asyncio.Queue = q, data: Dict[str, Any] = payload) -> None:
+ try:
+ target_q.put_nowait(copy.deepcopy(data))
+ except asyncio.QueueFull:
+ pass
+ try:
+ loop.call_soon_threadsafe(_enqueue)
+ except RuntimeError:
+ continue
+
+ def _emit_event_locked(self, event_type: str, payload: Optional[Dict[str, Any]] = None, *, bump_revision: bool = False) -> Dict[str, Any]:
+ if bump_revision:
+ self._next_revision_locked()
+ event_payload: Dict[str, Any] = {
+ "type": event_type,
+ "run_id": self.run_id,
+ "revision": self.revision,
+ }
+ if payload:
+ event_payload.update(payload)
+ self._enqueue_sse_payload(event_payload)
+ return event_payload
+
+ def _sync_status_from_workers_locked(self) -> None:
+ if self.status in {"stopping", "stopped", "finished"}:
+ return
+ workers = list(self._worker_runtime.values())
+ if not workers:
+ return
+ statuses = {str(worker.get("status") or "") for worker in workers}
+ if any(status == "failed" for status in statuses):
+ self.status = "failed"
+ return
+ if statuses and statuses.issubset({"succeeded", "stopped"}):
+ self.status = "finished"
+ return
+ self.status = "running"
+
+ def _finalize_worker_runtimes_locked(self, final_status: str) -> None:
+ status = str(final_status or "").strip().lower()
+ if status != "stopped":
+ return
+ updated_at = self._now_iso()
+ for runtime in self._worker_runtime.values():
+ runtime["status"] = "stopped"
+ runtime["phase"] = "finish"
+ runtime["current_step"] = "stopped"
+ runtime["message"] = "任务已停止"
+ runtime["updated_at"] = updated_at
+ self._upsert_worker_step_locked(
+ runtime,
+ step_id="stopped",
+ level="info",
+ message="任务已停止",
+ updated_at=updated_at,
+ )
+
+ def _worker_status_from_step(self, step: str, level: str) -> str:
+ s = str(step or "").strip().lower()
+ lv = str(level or "").strip().lower()
+ if s in {"stopping"}:
+ return "stopping"
+ if s in {"stopped", "auto_stop"}:
+ return "stopped"
+ if s in {"retry", "wait"}:
+ return "waiting"
+ if s == "runtime" or lv == "error":
+ return "failed"
+ if s in {"cpa_upload", "sync", "saved"}:
+ return "postprocessing"
+ if s in {"start", "dedupe", "mode"}:
+ return "preparing"
+ if s in self._REGISTRATION_STEPS:
+ if s == "get_token" and lv == "success":
+ return "succeeded" if self.completion_semantics == "registration_only" else "postprocessing"
+ return "registering"
+ return "running" if self.status in {"running", "starting"} else self.status
+
+ def _worker_phase_from_step(self, step: str) -> str:
+ s = str(step or "").strip().lower()
+ if s in {"start", "dedupe", "mode"}:
+ return "prepare"
+ if s in self._REGISTRATION_STEPS:
+ return "register"
+ if s in {"saved", "cpa_upload", "sync", "retry", "wait"}:
+ return "postprocess"
+ if s in {"stopping", "stopped", "auto_stop", "shutdown"}:
+ return "finish"
+ return "prepare"
+
+ def _extract_email_from_event(self, event: Dict[str, Any]) -> str:
+ direct_email = str(event.get("account_email") or "").strip()
+ if direct_email:
+ return direct_email
+ message = str(event.get("message") or "")
+ match = re.search(r"([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})", message)
+ return match.group(1) if match else ""
+
+ def _upsert_worker_step_locked(self, runtime: Dict[str, Any], *, step_id: str, level: str, message: str, updated_at: str) -> Dict[str, Any]:
+ label = self._WORKER_STEP_DEFINITIONS.get(step_id, step_id or "运行步骤")
+ raw_status = str(level or "info").strip().lower()
+ if raw_status == "success":
+ status = "done"
+ elif raw_status == "error":
+ status = "error"
+ elif step_id in {"wait", "retry"}:
+ status = "active"
+ else:
+ status = "active"
+ steps: List[Dict[str, Any]] = runtime.setdefault("steps", [])
+ current = None
+ for item in steps:
+ if item.get("step_id") == step_id:
+ current = item
+ break
+ if current is None:
+ current = {
+ "step_id": step_id,
+ "id": step_id,
+ "label": label,
+ "status": status,
+ "message": message,
+ "started_at": updated_at,
+ "finished_at": updated_at if status in {"done", "error", "skipped"} else None,
+ "updated_at": updated_at,
+ }
+ steps.append(current)
+ else:
+ current["label"] = label
+ current["status"] = status
+ current["message"] = message
+ current["updated_at"] = updated_at
+ current.setdefault("started_at", updated_at)
+ if status in {"done", "error", "skipped"}:
+ current["finished_at"] = updated_at
+ else:
+ current["finished_at"] = None
+ return copy.deepcopy(current)
+
+ def _update_runtime_from_event_locked(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ raw_worker_id = event.get("worker_id")
+ try:
+ worker_id = int(raw_worker_id)
+ except (TypeError, ValueError):
+ return None
+
+ runtime = self._worker_runtime.get(worker_id)
+ if runtime is None:
+ runtime = self._empty_worker_runtime_locked(worker_id, str(event.get("worker_label") or f"W{worker_id}"))
+ self._worker_runtime[worker_id] = runtime
+
+ updated_at = str(event.get("iso_ts") or event.get("updated_at") or self._now_iso())
+ runtime["updated_at"] = updated_at
+ runtime["worker_label"] = str(event.get("worker_label") or runtime.get("worker_label") or f"W{worker_id}")
+
+ attempt = event.get("attempt")
+ if attempt not in (None, ""):
+ try:
+ runtime["attempt"] = int(attempt)
+ except (TypeError, ValueError):
+ pass
+
+ mail_provider = str(event.get("mail_provider") or "").strip()
+ if mail_provider:
+ runtime["mail_provider"] = mail_provider
+
+ email = self._extract_email_from_event(event)
+ if email:
+ runtime["account_email"] = email
+ runtime["email"] = email
+
+ message = str(event.get("message") or "").strip()
+ if message:
+ runtime["message"] = message
+
+ step = str(event.get("step") or "").strip().lower()
+ level = str(event.get("level") or "info").strip().lower()
+ step_patch = None
+ if step:
+ runtime["current_step"] = step
+ runtime["phase"] = self._worker_phase_from_step(step)
+ runtime["status"] = self._worker_status_from_step(step, level)
+ step_patch = self._upsert_worker_step_locked(runtime, step_id=step, level=level, message=message, updated_at=updated_at)
+ if step == "start":
+ runtime["steps"] = [step_patch]
+ if step in {"stopped", "auto_stop"}:
+ runtime["status"] = "stopped"
+ elif step == "runtime" or (level == "error" and step not in {"retry", "wait"}):
+ runtime["status"] = "failed"
+ else:
+ runtime["status"] = "running" if self.status not in {"stopping", "stopped"} else self.status
+
+ if self._focus_worker_id is None or self._focus_worker_id == worker_id:
+ self._focus_worker_id = worker_id
+ elif runtime["status"] in {"registering", "postprocessing", "failed", "waiting"}:
+ self._focus_worker_id = worker_id
+
+ self._sync_status_from_workers_locked()
+ return {
+ "worker": copy.deepcopy(runtime),
+ "step": step_patch,
+ }
+
+ def _aggregate_runtime_locked(self) -> Dict[str, Any]:
+ agg: Dict[str, Any] = {
+ "total": 0,
+ "starting": 0,
+ "preparing": 0,
+ "registering": 0,
+ "postprocessing": 0,
+ "waiting": 0,
+ "stopping": 0,
+ "stopped": 0,
+ "failed": 0,
+ "succeeded": 0,
+ "last_updated_at": None,
+ }
+ for runtime in self._worker_runtime.values():
+ agg["total"] += 1
+ status = str(runtime.get("status") or "").strip().lower()
+ if status in agg:
+ agg[status] += 1
+ updated_at = runtime.get("updated_at")
+ if updated_at and (agg["last_updated_at"] is None or str(updated_at) > str(agg["last_updated_at"])):
+ agg["last_updated_at"] = updated_at
+ return agg
+
+ def broadcast(self, event: Dict[str, Any]) -> None:
+ with self._task_lock:
+ payload = dict(event)
+ payload.setdefault("ts", datetime.now().strftime("%H:%M:%S"))
+ payload.setdefault("iso_ts", self._now_iso())
+ event_type = str(payload.get("type") or "").strip()
+ if event_type:
+ self._emit_event_locked(event_type, payload, bump_revision=event_type != "heartbeat")
+ return
+
+ runtime_patch = self._update_runtime_from_event_locked(payload)
+ self._emit_event_locked(
+ "log.appended",
+ {
+ "log": {
+ "ts": payload.get("ts", ""),
+ "level": payload.get("level", "info"),
+ "message": payload.get("message", ""),
+ "step": payload.get("step", ""),
+ "worker_id": payload.get("worker_id"),
+ "worker_label": payload.get("worker_label"),
+ }
+ },
+ bump_revision=True,
+ )
+ if runtime_patch:
+ self._emit_event_locked("worker.updated", {"worker": runtime_patch["worker"]})
+ if runtime_patch.get("step"):
+ self._emit_event_locked(
+ "worker.step.updated",
+ {
+ "worker_id": runtime_patch["worker"].get("worker_id"),
+ "worker": runtime_patch["worker"],
+ "step": runtime_patch["step"],
+ "focus_worker_id": self._focus_worker_id,
+ },
+ )
+ self._emit_event_locked("task.updated", {"task": self._task_snapshot_locked()})
+ self._emit_event_locked("stats.updated", {"stats": self._stats_snapshot_locked()})
+
+ def _make_emitter(self) -> EventEmitter:
+ thread_q: queue.Queue = queue.Queue(maxsize=500)
+
+ def _bridge() -> None:
+ while True:
+ try:
+ event = thread_q.get(timeout=0.2)
+ if event is None:
+ break
+ try:
+ self.broadcast(event)
+ except Exception as exc:
+ try:
+ print(f"[bridge] log event dropped: {exc}")
+ except Exception:
+ pass
+ except queue.Empty:
+ if self.stop_event.is_set() and thread_q.empty():
+ break
+
+ bridge_thread = threading.Thread(target=_bridge, daemon=True)
+ bridge_thread.start()
+ self._bridge_thread = bridge_thread
+ self._bridge_q = thread_q
+ return EventEmitter(q=thread_q, cli_mode=True)
+
+ def _stop_bridge(self) -> None:
+ if hasattr(self, "_bridge_q"):
+ try:
+ self._bridge_q.put_nowait(None)
+ except queue.Full:
+ pass
+
+ def start_task(
+ self,
+ proxy: str,
+ worker_count: int = 1,
+ target_count: int = 0,
+ cpa_target_count: Optional[int] = None,
+ sub2api_target_count: Optional[int] = None,
+ ) -> None:
+ cpa_target = None if cpa_target_count is None else max(0, int(cpa_target_count))
+ sub2api_target = None if sub2api_target_count is None else max(0, int(sub2api_target_count))
+ config_snapshot = _get_sync_config()
+ upload_mode = str(config_snapshot.get("upload_mode", "snapshot") or "snapshot").strip().lower()
+ if upload_mode not in ("snapshot", "decoupled"):
+ upload_mode = "snapshot"
+ try:
+ mail_router = MultiMailRouter(config_snapshot)
+ except Exception as exc:
+ raise RuntimeError(str(exc)) from exc
+ pool_maintainer = _get_pool_maintainer(config_snapshot)
+ auto_sync_enabled = _is_auto_sync_enabled(config_snapshot)
+
+ with self._task_lock:
+ if self.status in ("starting", "running", "stopping"):
+ raise RuntimeError("任务正在运行或停止中")
+ n = max(1, min(int(worker_count or 1), 10))
+ now = self._now_iso()
+ self.run_id = self._new_run_id()
+ self.revision = 0
+ self.status = "starting"
+ self.stop_event.clear()
+ self.current_proxy = proxy
+ self.worker_count = n
+ self.upload_mode = upload_mode
+ self.target_count = max(0, target_count)
+ self.run_success_count = 0
+ self.run_fail_count = 0
+ self.platform_success_count = {name: 0 for name in UPLOAD_PLATFORMS}
+ self.platform_fail_count = {name: 0 for name in UPLOAD_PLATFORMS}
+ self.platform_backlog_count = {name: 0 for name in UPLOAD_PLATFORMS}
+ self._upload_queues = {}
+ self._worker_threads = {}
+ self._worker_runtime = {
+ wid: self._empty_worker_runtime_locked(wid)
+ for wid in range(1, n + 1)
+ }
+ self._focus_worker_id = 1 if n > 0 else None
+ self.created_at = now
+ self.started_at = now
+ self.finished_at = None
+ self.stop_reason = ""
+ self.last_error = ""
+ self.completion_semantics = "requires_postprocess" if auto_sync_enabled else "registration_only"
+ self._emit_event_locked("task.updated", {"task": self._task_snapshot_locked()}, bump_revision=True)
+ self._emit_event_locked("snapshot", {"snapshot": self._status_snapshot_locked()})
+
+ emitter = self._make_emitter()
+ emitter.info(
+ f"上传策略: {'串行补平台(先CPA后Sub2Api)' if upload_mode == 'snapshot' else '双平台同传(单账号双上传)'}",
+ step="mode",
+ )
+
+
+ upload_remaining: Dict[str, Optional[int]] = {
+ "cpa": cpa_target,
+ "sub2api": sub2api_target,
+ }
+ snapshot_strict_serial = (
+ upload_mode == "snapshot"
+ and cpa_target is not None
+ and sub2api_target is not None
+ )
+ token_states: Dict[str, Dict[str, Any]] = {}
+ token_states_lock = threading.RLock()
+ seen_runtime_identities: set[str] = _load_local_token_identity_keys()
+ seen_runtime_identities_lock = threading.RLock()
+ upload_queues: Dict[str, queue.Queue] = {}
+ upload_workers: Dict[str, threading.Thread] = {}
+ producers_done = threading.Event()
+
+ def _reserve_upload_slot(platform: str) -> bool:
+ with self._task_lock:
+ remain = upload_remaining.get(platform)
+ if remain is None:
+ return True
+ if remain <= 0:
+ return False
+ upload_remaining[platform] = remain - 1
+ return True
+
+ def _release_upload_slot(platform: str) -> None:
+ with self._task_lock:
+ remain = upload_remaining.get(platform)
+ if remain is not None:
+ upload_remaining[platform] = remain + 1
+
+ def _decoupled_slots_exhausted() -> bool:
+ """仅在双平台同传 + 有限配额场景下判断是否已无可用上传槽位。"""
+ if upload_mode != "decoupled":
+ return False
+ with self._task_lock:
+ finite_remains = [
+ remain
+ for remain in upload_remaining.values()
+ if remain is not None
+ ]
+ return bool(finite_remains) and all(remain <= 0 for remain in finite_remains)
+
+ def _reserve_snapshot_serial_platform() -> Optional[str]:
+ with self._task_lock:
+ cpa_remain = upload_remaining.get("cpa")
+ if cpa_remain is not None and cpa_remain > 0:
+ upload_remaining["cpa"] = cpa_remain - 1
+ return "cpa"
+ sub2api_remain = upload_remaining.get("sub2api")
+ if sub2api_remain is not None and sub2api_remain > 0:
+ upload_remaining["sub2api"] = sub2api_remain - 1
+ return "sub2api"
+ return None
+
+ def _record_platform_result(platform: str, ok: bool) -> None:
+ if platform not in UPLOAD_PLATFORMS:
+ return
+ with self._task_lock:
+ if ok:
+ self.platform_success_count[platform] = self.platform_success_count.get(platform, 0) + 1
+ else:
+ self.platform_fail_count[platform] = self.platform_fail_count.get(platform, 0) + 1
+
+ def _register_runtime_identity(email: str, refresh_token: str) -> bool:
+ keys = _sub2api_identity_keys(email=email, refresh_token=refresh_token)
+ if not keys:
+ return True
+ with seen_runtime_identities_lock:
+ for key in keys:
+ if key in seen_runtime_identities:
+ return False
+ seen_runtime_identities.update(keys)
+ return True
+
+ def _refresh_backlog() -> None:
+ with self._task_lock:
+ if upload_mode != "decoupled":
+ self.platform_backlog_count = {name: 0 for name in UPLOAD_PLATFORMS}
+ return
+ self.platform_backlog_count = {
+ platform: q.qsize()
+ for platform, q in upload_queues.items()
+ }
+
+ def _apply_final_result(email: str, prefix: str, ok: bool) -> None:
+ if ok:
+ with self._task_lock:
+ self.success_count += 1
+ self.run_success_count += 1
+ _save_state(self.success_count, self.fail_count)
+ should_stop = self.target_count > 0 and self.run_success_count >= self.target_count
+ if should_stop:
+ emitter.success(
+ f"{prefix}本轮已达目标 {self.target_count} 个,自动停止",
+ step="auto_stop",
+ )
+ self.stop_event.set()
+ else:
+ with self._task_lock:
+ self.fail_count += 1
+ self.run_fail_count += 1
+ _save_state(self.success_count, self.fail_count)
+ emitter.error(f"{prefix}平台上传未完成,本次不计入成功: {email}", step="retry")
+
+ def _auto_sync(file_name: str, email: str, em: "EventEmitter") -> bool:
+ cfg = config_snapshot
+ if not _is_auto_sync_enabled(cfg):
+ return True
+ base_url = cfg.get("base_url", "").strip()
+ bearer = cfg.get("bearer_token", "").strip()
+ if not base_url or not bearer:
+ em.error("自动同步配置缺少平台地址或 Token,请先保存配置", step="sync")
+ return False
+
+ em.info(f"正在自动同步 {email}...", step="sync")
+ fpath = os.path.join(TOKENS_DIR, file_name)
+ try:
+ with open(fpath, "r", encoding="utf-8") as f:
+ token_data = json.load(f)
+ except Exception as e:
+ em.error(f"自动同步异常: 读取本地 Token 失败: {e}", step="sync")
+ return False
+
+ last_status = 0
+ last_body = ""
+ for attempt in range(3):
+ try:
+ result = _push_account_api_with_dedupe(
+ base_url=base_url,
+ bearer=bearer,
+ email=email,
+ token_data=token_data,
+ check_before=(attempt == 0),
+ check_after=True,
+ )
+ last_status = int(result.get("status") or 0)
+ last_body = str(result.get("body") or "")
+ if result.get("ok"):
+ if not _mark_token_uploaded_platform(fpath, "sub2api"):
+ em.warn(f"自动同步成功但本地标记失败: {email}", step="sync")
+ reason = str(result.get("reason") or "")
+ if reason == "updated_existing_before_create":
+ em.success(
+ f"自动同步命中已存在账号并更新凭据: {email} (id={result.get('existing_id', '-')})",
+ step="sync",
+ )
+ elif reason == "exists_before_create_update_failed":
+ em.warn(
+ f"自动同步命中已存在账号但更新失败,保持远端现状: {email} "
+ f"(id={result.get('existing_id', '-')}, status={result.get('update_status', '-')}) "
+ f"{str(result.get('update_body') or '')[:120]}",
+ step="sync",
+ )
+ elif result.get("skipped"):
+ em.success(f"自动同步成功: {email}", step="sync")
+ else:
+ em.success(f"自动同步成功: {email}", step="sync")
+ return True
+ except Exception as e:
+ last_status = 0
+ last_body = str(e)
+ if attempt < 2:
+ time.sleep(2 ** attempt)
+
+ em.error(f"自动同步失败({last_status}): {last_body[:120]}", step="sync")
+ return False
+
+ def _upload_to_cpa(file_name: str, file_path: str, token_json: str, email: str, prefix: str) -> bool:
+ if not pool_maintainer:
+ return True
+ try:
+ td = json.loads(token_json)
+ cpa_ok = pool_maintainer.upload_token(file_name, td, proxy=proxy or "")
+ if cpa_ok:
+ if not _mark_token_uploaded_platform(file_path, "cpa"):
+ emitter.warn(f"{prefix}CPA 上传成功但本地标记失败: {email}", step="cpa_upload")
+ emitter.success(f"{prefix}CPA 上传成功: {email}", step="cpa_upload")
+ else:
+ emitter.error(f"{prefix}CPA 上传失败: {email}", step="cpa_upload")
+ return cpa_ok
+ except Exception as ex:
+ emitter.error(f"{prefix}CPA 上传异常: {ex}", step="cpa_upload")
+ return False
+
+ def _upload_to_sub2api(file_name: str, email: str, refresh_token: str, prefix: str) -> bool:
+ if not auto_sync_enabled:
+ return True
+ if not refresh_token:
+ emitter.error(f"{prefix}缺少 refresh_token,无法自动同步: {email}", step="sync")
+ return False
+ return _auto_sync(file_name, email, emitter)
+
+ def _register_decoupled_token(
+ token_key: str,
+ email: str,
+ prefix: str,
+ required_platforms: set[str],
+ failed_platforms: set[str],
+ ) -> None:
+ final_ok: Optional[bool] = None
+ no_required_platforms = False
+ with token_states_lock:
+ token_states[token_key] = {
+ "email": email,
+ "prefix": prefix,
+ "required": set(required_platforms),
+ "done": set(),
+ "failed": set(failed_platforms),
+ "finalized": False,
+ }
+ state = token_states[token_key]
+ if state["failed"]:
+ state["finalized"] = True
+ token_states.pop(token_key, None)
+ final_ok = False
+ elif not state["required"]:
+ state["finalized"] = True
+ token_states.pop(token_key, None)
+ no_required_platforms = True
+ if final_ok is not None:
+ _apply_final_result(email, prefix, final_ok)
+ return
+ if no_required_platforms:
+ # 有限配额耗尽后,后续注册不应继续计入成功。
+ if _decoupled_slots_exhausted():
+ emitter.info(
+ f"{prefix}平台目标已满足,跳过本次上传且不计成功: {email}",
+ step="auto_stop",
+ )
+ self.stop_event.set()
+ return
+ # 兼容手动启动且无上传平台/无限配额场景:保留“注册成功”计数行为。
+ _apply_final_result(email, prefix, True)
+
+ def _complete_decoupled_platform(token_key: str, platform: str, ok: bool) -> None:
+ final_ok: Optional[bool] = None
+ email = "unknown"
+ prefix = ""
+ with token_states_lock:
+ state = token_states.get(token_key)
+ if not state or state.get("finalized"):
+ return
+ if ok:
+ state["done"].add(platform)
+ else:
+ state["failed"].add(platform)
+ email = state.get("email", "unknown")
+ prefix = state.get("prefix", "")
+ if state["failed"]:
+ state["finalized"] = True
+ token_states.pop(token_key, None)
+ final_ok = False
+ elif state["required"].issubset(state["done"]):
+ state["finalized"] = True
+ token_states.pop(token_key, None)
+ final_ok = True
+ if final_ok is not None:
+ _apply_final_result(email, prefix, final_ok)
+
+ def _enqueue_upload_job(platform: str, job: Dict[str, Any], prefix: str) -> None:
+ q = upload_queues.get(platform)
+ if not q:
+ _release_upload_slot(platform)
+ _complete_decoupled_platform(job["token_key"], platform, False)
+ return
+ try:
+ q.put_nowait(job)
+ _refresh_backlog()
+ except queue.Full:
+ emitter.error(f"{prefix}{platform.upper()} 上传队列已满,跳过: {job.get('email', 'unknown')}", step="sync")
+ _release_upload_slot(platform)
+ _complete_decoupled_platform(job["token_key"], platform, False)
+
+ def _upload_worker_loop(platform: str) -> None:
+ q = upload_queues[platform]
+ while True:
+ if producers_done.is_set() and q.empty():
+ break
+ try:
+ job = q.get(timeout=0.3)
+ except queue.Empty:
+ _refresh_backlog()
+ continue
+
+ _refresh_backlog()
+ ok = False
+ if platform == "cpa":
+ ok = _upload_to_cpa(
+ file_name=job["file_name"],
+ file_path=job["file_path"],
+ token_json=job["token_json"],
+ email=job["email"],
+ prefix=job.get("prefix", ""),
+ )
+ elif platform == "sub2api":
+ ok = _upload_to_sub2api(
+ file_name=job["file_name"],
+ email=job["email"],
+ refresh_token=job.get("refresh_token", ""),
+ prefix=job.get("prefix", ""),
+ )
+ _record_platform_result(platform, ok)
+ if not ok:
+ _release_upload_slot(platform)
+ _complete_decoupled_platform(job["token_key"], platform, ok)
+ q.task_done()
+ _refresh_backlog()
+
+ def _worker_loop(worker_id: int) -> None:
+ worker_label = f"W{worker_id}"
+ prefix = f"[{worker_label}] " if n > 1 else ""
+ worker_emitter = emitter.bind(worker_id=worker_id, worker_label=worker_label)
+ count = 0
+ while not self.stop_event.is_set():
+ if _decoupled_slots_exhausted():
+ worker_emitter.info(f"{prefix}双平台目标已满足,停止新增注册", step="auto_stop")
+ self.stop_event.set()
+ break
+ count += 1
+ provider_name, provider = mail_router.next_provider()
+ attempt_emitter = worker_emitter.bind(mail_provider=provider_name)
+ attempt_emitter.info(
+ f"{prefix}>>> 第 {count} 次注册 (邮箱: {provider_name}) <<<",
+ step="start",
+ attempt=count,
+ )
+ try:
+ token_json = run(
+ proxy=proxy or None,
+ emitter=attempt_emitter,
+ stop_event=self.stop_event,
+ mail_provider=provider,
+ proxy_pool_config={
+ "enabled": bool(config_snapshot.get("proxy_pool_enabled", False)),
+ "api_url": str(config_snapshot.get("proxy_pool_api_url", "")).strip(),
+ "auth_mode": str(config_snapshot.get("proxy_pool_auth_mode", "query")).strip().lower(),
+ "api_key": str(config_snapshot.get("proxy_pool_api_key", "")).strip(),
+ "count": config_snapshot.get("proxy_pool_count", 1),
+ "country": str(config_snapshot.get("proxy_pool_country", "US") or "US").strip().upper(),
+ },
+ )
+
+ if self.stop_event.is_set() and not token_json:
+ break
+
+ if token_json:
+ mail_router.report_success(provider_name)
+ try:
+ t_data = json.loads(token_json)
+ fname_email = t_data.get("email", "unknown").replace("@", "_")
+ refresh_token = str(t_data.get("refresh_token", "") or "").strip()
+ email = str(t_data.get("email", "unknown") or "unknown").strip()
+ except Exception:
+ fname_email = "unknown"
+ refresh_token = ""
+ email = "unknown"
+
+ if not _register_runtime_identity(email, refresh_token):
+ attempt_emitter.warn(
+ f"{prefix}检测到重复账号(同邮箱/refresh_token),已跳过: {email}",
+ step="dedupe",
+ account_email=email,
+ )
+ continue
+
+ file_name = f"token_{fname_email}_{time.time_ns()}.json"
+ file_path = os.path.join(TOKENS_DIR, file_name)
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.write(token_json)
+
+ attempt_emitter.success(f"{prefix}Token 已保存: {file_name}", step="saved", account_email=email)
+ self.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "token_saved",
+ "message": file_name,
+ "step": "saved",
+ "worker_id": worker_id,
+ "worker_label": worker_label,
+ "mail_provider": provider_name,
+ "attempt": count,
+ "account_email": email,
+ })
+
+ if upload_mode == "snapshot":
+ if snapshot_strict_serial:
+ selected_platform = _reserve_snapshot_serial_platform()
+ if selected_platform == "cpa":
+ attempt_emitter.info(f"{prefix}串行模式:本次仅上传 CPA -> {email}", step="cpa_upload", account_email=email)
+ cpa_ok = _upload_to_cpa(file_name, file_path, token_json, email, prefix) if pool_maintainer else True
+ _record_platform_result("cpa", cpa_ok)
+ if not cpa_ok:
+ _release_upload_slot("cpa")
+ _apply_final_result(email, prefix, cpa_ok)
+ elif selected_platform == "sub2api":
+ attempt_emitter.info(f"{prefix}串行模式:本次仅上传 Sub2Api -> {email}", step="sync", account_email=email)
+ sub2api_ok = _upload_to_sub2api(file_name, email, refresh_token, prefix) if auto_sync_enabled else True
+ _record_platform_result("sub2api", sub2api_ok)
+ if not sub2api_ok:
+ _release_upload_slot("sub2api")
+ _apply_final_result(email, prefix, sub2api_ok)
+ else:
+ attempt_emitter.info(f"{prefix}串行模式目标已满足,停止新增上传: {email}", step="auto_stop", account_email=email)
+ self.stop_event.set()
+ else:
+ cpa_ok = True
+ cpa_required = False
+ if pool_maintainer:
+ cpa_required = _reserve_upload_slot("cpa")
+ if pool_maintainer and not cpa_required:
+ attempt_emitter.info(f"{prefix}CPA 已达目标阈值,跳过上传: {email}", step="cpa_upload", account_email=email)
+ if pool_maintainer and cpa_required:
+ cpa_ok = _upload_to_cpa(file_name, file_path, token_json, email, prefix)
+ _record_platform_result("cpa", cpa_ok)
+ if not cpa_ok:
+ _release_upload_slot("cpa")
+
+ sub2api_ok = True
+ sub2api_required = False
+ if auto_sync_enabled:
+ sub2api_required = _reserve_upload_slot("sub2api")
+ if auto_sync_enabled and not sub2api_required:
+ attempt_emitter.info(f"{prefix}Sub2Api 已达目标阈值,跳过同步: {email}", step="sync", account_email=email)
+ if auto_sync_enabled and sub2api_required:
+ sub2api_ok = _upload_to_sub2api(file_name, email, refresh_token, prefix)
+ _record_platform_result("sub2api", sub2api_ok)
+ if not sub2api_ok:
+ _release_upload_slot("sub2api")
+
+ _apply_final_result(email, prefix, cpa_ok and sub2api_ok)
+ else:
+ required_platforms: set[str] = set()
+ failed_platforms: set[str] = set()
+
+ if pool_maintainer:
+ if _reserve_upload_slot("cpa"):
+ required_platforms.add("cpa")
+ else:
+ attempt_emitter.info(f"{prefix}CPA 已达目标阈值,跳过上传: {email}", step="cpa_upload", account_email=email)
+
+ if auto_sync_enabled:
+ if _reserve_upload_slot("sub2api"):
+ if refresh_token:
+ required_platforms.add("sub2api")
+ else:
+ failed_platforms.add("sub2api")
+ _release_upload_slot("sub2api")
+ attempt_emitter.error(f"{prefix}缺少 refresh_token,无法自动同步: {email}", step="sync", account_email=email)
+ else:
+ attempt_emitter.info(f"{prefix}Sub2Api 已达目标阈值,跳过同步: {email}", step="sync", account_email=email)
+
+ token_key = file_name
+ _register_decoupled_token(token_key, email, prefix, required_platforms, failed_platforms)
+
+ base_job = {
+ "token_key": token_key,
+ "file_name": file_name,
+ "file_path": file_path,
+ "token_json": token_json,
+ "email": email,
+ "refresh_token": refresh_token,
+ "prefix": prefix,
+ }
+ if "cpa" in required_platforms:
+ _enqueue_upload_job("cpa", base_job, prefix)
+ if "sub2api" in required_platforms:
+ _enqueue_upload_job("sub2api", base_job, prefix)
+ else:
+ mail_router.report_failure(provider_name)
+ with self._task_lock:
+ self.fail_count += 1
+ self.run_fail_count += 1
+ self.last_error = f"注册失败: worker={worker_id}"
+ _save_state(self.success_count, self.fail_count)
+ self.status = "running"
+ attempt_emitter.error(f"{prefix}本次注册失败,稍后重试...", step="retry")
+
+ except Exception as e:
+ mail_router.report_failure(provider_name)
+ with self._task_lock:
+ self.fail_count += 1
+ self.run_fail_count += 1
+ self.last_error = str(e)
+ _save_state(self.success_count, self.fail_count)
+ attempt_emitter.error(f"{prefix}发生未捕获异常: {e}", step="runtime")
+
+ if self.stop_event.is_set():
+ break
+
+ wait = random.randint(5, 30)
+ attempt_emitter.info(f"{prefix}休息 {wait} 秒后继续...", step="wait")
+ self.stop_event.wait(wait)
+
+ if upload_mode == "decoupled":
+ upload_queues = {
+ platform: queue.Queue(maxsize=2000)
+ for platform in UPLOAD_PLATFORMS
+ }
+ with self._task_lock:
+ self._upload_queues = upload_queues
+ _refresh_backlog()
+ for platform in UPLOAD_PLATFORMS:
+ t = threading.Thread(target=_upload_worker_loop, args=(platform,), daemon=True)
+ upload_workers[platform] = t
+ t.start()
+
+ def _monitor() -> None:
+ with self._task_lock:
+ workers = list(self._worker_threads.values())
+ for t in workers:
+ t.join()
+ if upload_mode == "decoupled":
+ producers_done.set()
+ for ut in upload_workers.values():
+ ut.join()
+ stale_results: List[Dict[str, Any]] = []
+ with token_states_lock:
+ for token_key in list(token_states.keys()):
+ state = token_states.pop(token_key, None)
+ if state and not state.get("finalized"):
+ stale_results.append(state)
+ for state in stale_results:
+ _apply_final_result(state.get("email", "unknown"), state.get("prefix", ""), False)
+ with self._task_lock:
+ self._upload_queues = {}
+ self.platform_backlog_count = {name: 0 for name in UPLOAD_PLATFORMS}
+ self._emit_event_locked("stats.updated", {"stats": self._stats_snapshot_locked()}, bump_revision=True)
+
+ emitter.info("所有Worker已停止", step="stopped")
+ self._stop_bridge()
+ with self._task_lock:
+ self._worker_threads.clear()
+ self.worker_count = 0
+ self.finished_at = self._now_iso()
+ if self.status == "stopping":
+ self.status = "stopped"
+ self.stop_reason = self.stop_reason or "manual_stop"
+ elif self.status == "failed":
+ pass
+ elif self.run_fail_count > 0 and self.run_success_count == 0:
+ self.status = "failed"
+ self.stop_reason = self.stop_reason or "run_failed"
+ else:
+ self.status = "finished"
+ if self.status == "stopped":
+ self._finalize_worker_runtimes_locked("stopped")
+ self._sync_status_from_workers_locked()
+ self._emit_event_locked("task.finished", {"task": self._task_snapshot_locked()}, bump_revision=True)
+ self._emit_event_locked("snapshot", {"snapshot": self._status_snapshot_locked()})
+
+ for wid in range(1, n + 1):
+ t = threading.Thread(target=_worker_loop, args=(wid,), daemon=True)
+ with self._task_lock:
+ self._worker_threads[wid] = t
+ t.start()
+
+ with self._task_lock:
+ self.status = "running"
+ self._emit_event_locked("task.updated", {"task": self._task_snapshot_locked()}, bump_revision=True)
+ self._emit_event_locked("snapshot", {"snapshot": self._status_snapshot_locked()})
+
+ self.thread = threading.Thread(target=_monitor, daemon=True)
+ self.thread.start()
+
+ def stop_task(self) -> None:
+ with self._task_lock:
+ if self.status in {"starting", "running", "failed"}:
+ self.status = "stopping"
+ self.stop_reason = "manual_stop"
+ self.stop_event.set()
+ self._emit_event_locked("task.updated", {"task": self._task_snapshot_locked()}, bump_revision=True)
+ self._emit_event_locked("snapshot", {"snapshot": self._status_snapshot_locked()})
+ else:
+ return
+ self.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "info",
+ "message": "收到停止请求,等待当前注册流程收尾...",
+ "step": "stopping",
+ })
+
+
+_state = TaskState()
+
+
+def request_service_shutdown() -> None:
+ """供外部启动器调用,通知服务进入收尾停止流程。"""
+ _service_shutdown_event.set()
+
+ try:
+ _state.broadcast({
+ "level": "info",
+ "message": "收到服务关闭请求,正在停止任务与后台维护线程...",
+ "step": "shutdown",
+ })
+ except Exception:
+ pass
+
+ try:
+ _state.stop_task()
+ except Exception:
+ pass
+
+ try:
+ _stop_auto_maintain()
+ except Exception:
+ pass
+
+ try:
+ _stop_sub2api_auto_maintain()
+ except Exception:
+ pass
+
+
+# 自动维护后台任务
+_auto_maintain_thread: Optional[threading.Thread] = None
+_auto_maintain_stop: Optional[threading.Event] = None
+_auto_maintain_ctl_lock = threading.Lock()
+_pool_maintain_lock = threading.Lock()
+
+
+def _get_pool_maintainer(cfg: Optional[Dict[str, Any]] = None) -> Optional[PoolMaintainer]:
+ cfg = cfg or _get_sync_config()
+ base_url = str(cfg.get("cpa_base_url", "")).strip()
+ token = str(cfg.get("cpa_token", "")).strip()
+ if not base_url or not token:
+ return None
+ return PoolMaintainer(
+ cpa_base_url=base_url,
+ cpa_token=token,
+ min_candidates=int(cfg.get("min_candidates", 800)),
+ used_percent_threshold=int(cfg.get("used_percent_threshold", 95)),
+ )
+
+
+def _get_sub2api_maintainer(cfg: Optional[Dict[str, Any]] = None) -> Optional[Sub2ApiMaintainer]:
+ cfg = cfg or _get_sync_config()
+ base_url = str(cfg.get("base_url", "")).strip()
+ bearer = str(cfg.get("bearer_token", "")).strip()
+ email = str(cfg.get("email", "")).strip()
+ password = str(cfg.get("password", "")).strip()
+ if not base_url:
+ return None
+ if not bearer and not (email and password):
+ return None
+ return Sub2ApiMaintainer(
+ base_url=base_url,
+ bearer_token=bearer,
+ min_candidates=int(cfg.get("sub2api_min_candidates", 200)),
+ email=email,
+ password=password,
+ )
+
+
+# ==========================================
+# API 路由
+# ==========================================
+
+
+class StartRequest(BaseModel):
+ proxy: str = ""
+ worker_count: int = 1
+
+
+class ProxyCheckRequest(BaseModel):
+ proxy: str = ""
+
+
+class ProxyPoolTestRequest(BaseModel):
+ enabled: bool = True
+ api_url: str = "https://zenproxy.top/api/fetch"
+ auth_mode: str = "query" # "header" | "query"
+ api_key: str = ""
+ count: int = 1
+ country: str = "US"
+
+
+class ProxyPoolConfigRequest(BaseModel):
+ proxy_pool_enabled: bool = True
+ proxy_pool_api_url: str = "https://zenproxy.top/api/fetch"
+ proxy_pool_auth_mode: str = "query" # "header" | "query"
+ proxy_pool_api_key: str = ""
+ proxy_pool_count: int = 1
+ proxy_pool_country: str = "US"
+
+
+class ProxySaveRequest(BaseModel):
+ proxy: str = ""
+ auto_register: bool = False
+
+
+class SyncConfigRequest(BaseModel):
+ base_url: str # Sub2Api 平台地址
+ bearer_token: str = "" # 管理员 JWT(可选)
+ email: str = "" # 管理员邮箱
+ password: str = "" # 管理员密码
+ account_name: str = "AutoReg"
+ auto_sync: bool = True
+ upload_mode: str = "snapshot" # "snapshot" | "decoupled"
+ sub2api_min_candidates: int = 200
+ sub2api_auto_maintain: bool = False
+ sub2api_maintain_interval_minutes: int = 30
+ sub2api_maintain_actions: Dict[str, bool] = Field(default_factory=dict)
+ multithread: bool = False
+ thread_count: int = 3
+ auto_register: bool = False
+
+
+class SyncNowRequest(BaseModel):
+ filenames: List[str] = [] # 空列表 = 同步全部
+
+
+class UploadModeRequest(BaseModel):
+ upload_mode: str = "snapshot" # "snapshot" | "decoupled"
+
+
+@app.get("/", response_class=HTMLResponse)
+async def index() -> HTMLResponse:
+ html_path = STATIC_DIR / "index.html"
+ if html_path.exists():
+ return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
+ return HTMLResponse("前端文件未找到
", status_code=404)
+
+
+@app.post("/api/start")
+async def api_start(req: StartRequest) -> Dict[str, Any]:
+ try:
+ _state.start_task(req.proxy, req.worker_count)
+ except RuntimeError as e:
+ raise HTTPException(status_code=409, detail=str(e))
+ snapshot = _state.get_status_snapshot()
+ return {
+ "run_id": snapshot["task"].get("run_id"),
+ "task": snapshot["task"],
+ "runtime": snapshot["runtime"],
+ "stats": snapshot["stats"],
+ "server_time": snapshot["server_time"],
+ }
+
+
+@app.post("/api/stop")
+async def api_stop() -> Dict[str, Any]:
+ if _state.status in {"stopped", "finished", "failed"} and not _state.run_id:
+ raise HTTPException(status_code=409, detail="没有正在运行的任务")
+ _state.stop_task()
+ return _state.get_status_snapshot()
+
+
+@app.post("/api/proxy/save")
+async def api_save_proxy(req: ProxySaveRequest) -> Dict[str, str]:
+ cfg = _get_sync_config()
+ cfg["proxy"] = req.proxy.strip()
+ cfg["auto_register"] = req.auto_register
+ _save_sync_config(cfg)
+ return {"status": "saved"}
+
+
+@app.get("/api/proxy")
+async def api_get_proxy() -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ return {
+ "proxy": cfg.get("proxy", ""),
+ "auto_register": cfg.get("auto_register", False),
+ }
+
+
+@app.get("/api/status")
+async def api_status() -> Dict[str, Any]:
+ return _state.get_status_snapshot()
+
+
+@app.get("/api/tokens")
+async def api_tokens() -> Dict[str, Any]:
+ def _read_tokens():
+ tokens = []
+ if os.path.isdir(TOKENS_DIR):
+ import re
+ def _sort_key(f):
+ m = re.search(r'_(\d{10,})\.json$', f)
+ return int(m.group(1)) if m else 0
+
+ all_files = [f for f in os.listdir(TOKENS_DIR) if f.endswith(".json")]
+ all_files.sort(key=_sort_key, reverse=True)
+ for fname in all_files:
+ fpath = os.path.join(TOKENS_DIR, fname)
+ try:
+ with open(fpath, "r", encoding="utf-8") as f:
+ content_raw = json.load(f)
+ content = content_raw if isinstance(content_raw, dict) else {}
+ uploaded_platforms = _extract_uploaded_platforms(content)
+ tokens.append(
+ {
+ "filename": fname,
+ "email": content.get("email", ""),
+ "expired": content.get("expired", ""),
+ "uploaded_platforms": uploaded_platforms,
+ "content": content,
+ }
+ )
+ except Exception:
+ pass
+ return tokens
+
+ tokens = await run_in_threadpool(_read_tokens)
+ return {"tokens": tokens}
+
+
+@app.delete("/api/tokens/{filename}")
+async def api_delete_token(filename: str) -> Dict[str, str]:
+ # 安全过滤:防止路径穿越
+ if "/" in filename or "\\" in filename or ".." in filename:
+ raise HTTPException(status_code=400, detail="非法文件名")
+ fpath = os.path.join(TOKENS_DIR, filename)
+ if not os.path.isfile(fpath):
+ raise HTTPException(status_code=404, detail="文件不存在")
+ os.remove(fpath)
+ return {"status": "deleted"}
+
+
+@app.get("/api/sync-config")
+async def api_get_sync_config() -> Dict[str, Any]:
+ """获取当前同步配置(脱敏)"""
+ cfg = _get_sync_config()
+ cfg["password"] = "" # 不回传密码
+ token = cfg.get("bearer_token", "")
+ cfg["bearer_token_preview"] = token[:12] + "..." if len(token) > 12 else (token or "")
+ cfg["bearer_token"] = "" # 不回传完整 token
+ # 脱敏 cpa_token
+ cpa_token = str(cfg.get("cpa_token", ""))
+ cfg["cpa_token_preview"] = (cpa_token[:12] + "...") if len(cpa_token) > 12 else (cpa_token or "")
+ cfg["cpa_token"] = ""
+ proxy_pool_api_key = str(cfg.get("proxy_pool_api_key", ""))
+ cfg["proxy_pool_api_key_preview"] = (
+ (proxy_pool_api_key[:8] + "...") if len(proxy_pool_api_key) > 8 else (proxy_pool_api_key or "")
+ )
+ cfg["proxy_pool_api_key"] = ""
+ # 脱敏 mail_provider_configs
+ raw_configs = cfg.get("mail_provider_configs") or {}
+ safe_configs: Dict[str, Dict] = {}
+ for pname, pcfg in raw_configs.items():
+ if not isinstance(pcfg, dict):
+ continue
+ sc = dict(pcfg)
+ for secret_key in ("bearer_token", "api_key", "admin_password"):
+ val = str(sc.get(secret_key, ""))
+ if val:
+ sc[f"{secret_key}_preview"] = (val[:8] + "...") if len(val) > 8 else val
+ sc.pop(secret_key, None)
+ safe_configs[pname] = sc
+ cfg["mail_provider_configs"] = safe_configs
+ cfg.setdefault("sub2api_min_candidates", 200)
+ cfg.setdefault("sub2api_auto_maintain", False)
+ cfg.setdefault("sub2api_maintain_interval_minutes", 30)
+ cfg.setdefault("upload_mode", "snapshot")
+ cfg.setdefault("multithread", False)
+ cfg.setdefault("thread_count", 3)
+ cfg.setdefault("proxy_pool_enabled", True)
+ cfg.setdefault("proxy_pool_api_url", "https://zenproxy.top/api/fetch")
+ cfg.setdefault("proxy_pool_auth_mode", "query")
+ cfg.setdefault("proxy_pool_count", 1)
+ cfg.setdefault("proxy_pool_country", "US")
+ cfg["auto_sync"] = _is_auto_sync_enabled(cfg)
+ return cfg
+
+
+@app.get("/api/proxy-pool/config")
+async def api_get_proxy_pool_config() -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ api_url = str(cfg.get("proxy_pool_api_url", "https://zenproxy.top/api/fetch") or "").strip()
+ if not api_url:
+ api_url = "https://zenproxy.top/api/fetch"
+ auth_mode = str(cfg.get("proxy_pool_auth_mode", "query") or "").strip().lower()
+ if auth_mode not in ("header", "query"):
+ auth_mode = "query"
+ try:
+ count = max(1, min(int(cfg.get("proxy_pool_count", 1) or 1), 20))
+ except (TypeError, ValueError):
+ count = 1
+ country = str(cfg.get("proxy_pool_country", "US") or "US").strip().upper() or "US"
+ api_key = str(cfg.get("proxy_pool_api_key", "") or "").strip()
+ return {
+ "proxy_pool_enabled": bool(cfg.get("proxy_pool_enabled", True)),
+ "proxy_pool_api_url": api_url,
+ "proxy_pool_auth_mode": auth_mode,
+ "proxy_pool_api_key": "",
+ "proxy_pool_api_key_preview": (api_key[:8] + "...") if len(api_key) > 8 else (api_key or ""),
+ "proxy_pool_count": count,
+ "proxy_pool_country": country,
+ }
+
+
+@app.post("/api/proxy-pool/config")
+async def api_set_proxy_pool_config(req: ProxyPoolConfigRequest) -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ proxy_pool_auth_mode = str(req.proxy_pool_auth_mode or "query").strip().lower()
+ if proxy_pool_auth_mode not in ("header", "query"):
+ proxy_pool_auth_mode = "query"
+
+ proxy_pool_api_url = str(req.proxy_pool_api_url or "https://zenproxy.top/api/fetch").strip()
+ if not proxy_pool_api_url:
+ proxy_pool_api_url = "https://zenproxy.top/api/fetch"
+
+ proxy_pool_api_key = req.proxy_pool_api_key.strip() if req.proxy_pool_api_key else ""
+ if not proxy_pool_api_key:
+ proxy_pool_api_key = str(cfg.get("proxy_pool_api_key", "") or "").strip()
+
+ try:
+ proxy_pool_count = max(1, min(int(req.proxy_pool_count), 20))
+ except (TypeError, ValueError):
+ proxy_pool_count = 1
+ proxy_pool_country = str(req.proxy_pool_country or "US").strip().upper() or "US"
+
+ cfg.update({
+ "proxy_pool_enabled": bool(req.proxy_pool_enabled),
+ "proxy_pool_api_url": proxy_pool_api_url,
+ "proxy_pool_auth_mode": proxy_pool_auth_mode,
+ "proxy_pool_api_key": proxy_pool_api_key,
+ "proxy_pool_count": proxy_pool_count,
+ "proxy_pool_country": proxy_pool_country,
+ })
+ _save_sync_config(cfg)
+ return {"status": "saved"}
+
+
+@app.post("/api/upload-mode")
+async def api_set_upload_mode(req: UploadModeRequest) -> Dict[str, Any]:
+ upload_mode = str(req.upload_mode or "snapshot").strip().lower()
+ if upload_mode not in ("snapshot", "decoupled"):
+ raise HTTPException(status_code=400, detail="upload_mode 仅支持 snapshot / decoupled")
+ cfg = _get_sync_config()
+ cfg["upload_mode"] = upload_mode
+ _save_sync_config(cfg)
+ # 空闲状态下同步到内存状态,便于前端立即看到当前策略
+ with _state._task_lock:
+ if _state.status == "idle":
+ _state.upload_mode = upload_mode
+ return {"status": "saved", "upload_mode": upload_mode}
+
+
+def _verify_sub2api_login(base_url: str, email: str, password: str) -> Dict[str, Any]:
+ """通过 HTTP API 验证 Sub2Api 平台登录凭据是否正确"""
+ from curl_cffi import requests as cffi_req
+
+ # 自动补全协议(优先 https://)
+ url = base_url.strip()
+ if not url.startswith(("http://", "https://")):
+ url = "https://" + url
+
+ login_url = url.rstrip("/") + "/api/v1/auth/login"
+ try:
+ resp = cffi_req.post(
+ login_url,
+ json={"email": email, "password": password},
+ impersonate="chrome",
+ timeout=15,
+ )
+ raw_body = resp.text
+ if resp.status_code != 200:
+ try:
+ err_body = json.loads(raw_body)
+ err_msg = err_body.get("message") or err_body.get("error") or raw_body[:200]
+ except json.JSONDecodeError:
+ err_msg = raw_body[:200]
+ return {"ok": False, "error": f"登录失败(HTTP {resp.status_code}): {err_msg}"}
+ try:
+ body = json.loads(raw_body)
+ except json.JSONDecodeError:
+ return {"ok": False, "error": f"服务器返回非 JSON 格式: {raw_body[:200]}"}
+
+ token = (
+ body.get("token")
+ or body.get("access_token")
+ or (body.get("data") or {}).get("token")
+ or (body.get("data") or {}).get("access_token")
+ or ""
+ )
+ return {"ok": True, "token": token}
+ except Exception as e:
+ return {"ok": False, "error": f"请求异常: {e}"}
+
+
+def _verify_sub2api_token(base_url: str, bearer_token: str) -> Dict[str, Any]:
+ from curl_cffi import requests as cffi_req
+
+ url = base_url.strip()
+ if not url.startswith(("http://", "https://")):
+ url = "https://" + url
+
+ verify_url = url.rstrip("/") + "/api/v1/admin/dashboard/stats"
+ try:
+ resp = cffi_req.get(
+ verify_url,
+ headers={
+ "Authorization": f"Bearer {bearer_token}",
+ "Accept": "application/json",
+ },
+ params={"timezone": "Asia/Shanghai"},
+ impersonate="chrome",
+ timeout=15,
+ )
+ if resp.status_code != 200:
+ return {"ok": False, "error": f"Bearer Token 验证失败: HTTP {resp.status_code}"}
+ return {"ok": True}
+ except Exception as e:
+ return {"ok": False, "error": f"Bearer Token 验证异常: {e}"}
+
+
+@app.post("/api/sync-config")
+async def api_set_sync_config(req: SyncConfigRequest) -> Dict[str, Any]:
+ """保存同步配置(先验证登录凭据)"""
+ cfg = _get_sync_config()
+ new_base_url = req.base_url.strip()
+ if new_base_url and not new_base_url.startswith(("http://", "https://")):
+ new_base_url = "https://" + new_base_url
+ new_email = req.email.strip() or str(cfg.get("email", "") or "").strip()
+ new_password = req.password.strip() if req.password else str(cfg.get("password", "") or "").strip()
+ bearer_token = req.bearer_token.strip() or str(cfg.get("bearer_token", "") or "").strip()
+
+ if not new_base_url:
+ raise HTTPException(status_code=400, detail="请填写平台地址")
+
+ verified_token = bearer_token
+ if new_email and new_password:
+ verify = await run_in_threadpool(_verify_sub2api_login, new_base_url, new_email, new_password)
+ if not verify["ok"]:
+ raise HTTPException(status_code=400, detail=verify["error"])
+ verified_token = str(verify.get("token") or "").strip() or bearer_token
+ elif bearer_token:
+ verify = await run_in_threadpool(_verify_sub2api_token, new_base_url, bearer_token)
+ if not verify["ok"]:
+ raise HTTPException(status_code=400, detail=verify["error"])
+ else:
+ raise HTTPException(status_code=400, detail="请填写 Bearer Token 或邮箱和密码")
+
+ upload_mode = str(req.upload_mode or "snapshot").strip().lower()
+ if upload_mode not in ("snapshot", "decoupled"):
+ upload_mode = "snapshot"
+
+ cfg.update({
+ "base_url": new_base_url,
+ "bearer_token": verified_token,
+ "email": new_email,
+ "password": new_password,
+ "account_name": req.account_name.strip(),
+ "auto_sync": req.auto_sync,
+ "upload_mode": upload_mode,
+ "sub2api_min_candidates": max(1, req.sub2api_min_candidates),
+ "sub2api_auto_maintain": req.sub2api_auto_maintain,
+ "sub2api_maintain_interval_minutes": max(5, req.sub2api_maintain_interval_minutes),
+ "sub2api_maintain_actions": _normalize_sub2api_maintain_actions(req.sub2api_maintain_actions),
+ "multithread": req.multithread,
+ "thread_count": max(1, min(req.thread_count, 10)),
+ "auto_register": req.auto_register,
+ })
+ # 清理历史遗留字段
+ cfg.pop("headful", None)
+ _save_sync_config(cfg)
+ _clear_sub2api_accounts_cache()
+
+ # 先停再启,确保旧线程已退出
+ _stop_sub2api_auto_maintain()
+ if req.sub2api_auto_maintain:
+ _start_sub2api_auto_maintain()
+
+ return {"status": "saved", "verified": True}
+
+
+@app.post("/api/sync-now")
+async def api_sync_now(req: SyncNowRequest) -> Dict[str, Any]:
+ """手动触发同步:将本地 Token 文件完整导入 Sub2Api 平台"""
+ def _sync_now() -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ base_url = str(cfg.get("base_url", "") or "").strip()
+ bearer = str(cfg.get("bearer_token", "") or "").strip()
+ if not base_url or not bearer:
+ raise HTTPException(status_code=400, detail="请先配置 Sub2Api 平台地址和 Bearer Token")
+
+ results = []
+ fnames = list(req.filenames or [])
+ if not fnames and os.path.isdir(TOKENS_DIR):
+ fnames = [f for f in os.listdir(TOKENS_DIR) if f.endswith(".json")]
+
+ for fname in fnames:
+ if "/" in fname or "\\" in fname or ".." in fname:
+ continue
+ fpath = os.path.join(TOKENS_DIR, fname)
+ if not os.path.isfile(fpath):
+ results.append({"file": fname, "ok": False, "error": "文件不存在"})
+ continue
+ try:
+ with open(fpath, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ email = data.get("email", fname)
+ result = _push_account_api_with_dedupe(
+ base_url=base_url,
+ bearer=bearer,
+ email=str(email),
+ token_data=data,
+ check_before=True,
+ check_after=True,
+ )
+ if result["ok"]:
+ _mark_token_uploaded_platform(fpath, "sub2api")
+ results.append({
+ "file": fname,
+ "email": email,
+ "ok": result["ok"],
+ "status": result["status"],
+ "body": str(result["body"] or "")[:200],
+ })
+ except Exception as e:
+ results.append({"file": fname, "ok": False, "error": str(e)})
+
+ ok_count = sum(1 for r in results if r["ok"])
+ fail_count = len(results) - ok_count
+ return {"total": len(results), "ok": ok_count, "fail": fail_count, "results": results}
+
+ return await run_in_threadpool(_sync_now)
+
+
+class Sub2ApiLoginRequest(BaseModel):
+ base_url: str
+ email: str
+ password: str
+
+
+@app.post("/api/sub2api-login")
+async def api_sub2api_login(req: Sub2ApiLoginRequest) -> Dict[str, Any]:
+ """用账号密码登录 Sub2Api 平台,自动获取并保存 Bearer Token"""
+ def _login() -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ base_url = req.base_url.strip()
+ if not base_url:
+ raise HTTPException(status_code=400, detail="请填写平台地址")
+ if not base_url.startswith(("http://", "https://")):
+ base_url = "https://" + base_url
+
+ login_url = base_url.rstrip("/") + "/api/v1/auth/login"
+ payload = json.dumps({"email": req.email, "password": req.password}).encode("utf-8")
+ request = urllib.request.Request(
+ login_url,
+ data=payload,
+ method="POST",
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=15) as resp:
+ raw_body = resp.read().decode("utf-8")
+ try:
+ body = json.loads(raw_body)
+ except json.JSONDecodeError:
+ raise HTTPException(status_code=502, detail=f"服务器返回非 JSON 格式: {raw_body[:200]}")
+ except urllib.error.HTTPError as exc:
+ raw = exc.read().decode("utf-8", "replace")
+ try:
+ err_body = json.loads(raw)
+ err_msg = err_body.get("message") or err_body.get("error") or raw[:200]
+ except json.JSONDecodeError:
+ err_msg = raw[:200]
+ raise HTTPException(status_code=exc.code, detail=f"登录失败: {err_msg}")
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"请求异常: {e}")
+
+ token = (
+ body.get("token")
+ or body.get("access_token")
+ or (body.get("data") or {}).get("token")
+ or (body.get("data") or {}).get("access_token")
+ or ""
+ )
+ if not token:
+ raise HTTPException(status_code=502, detail=f"响应中未找到 token 字段: {str(body)[:300]}")
+
+ cfg["base_url"] = base_url
+ cfg["bearer_token"] = token
+ _save_sync_config(cfg)
+ return {"ok": True, "token_preview": token[:16] + "..."}
+
+ return await run_in_threadpool(_login)
+
+
+@app.post("/api/check-proxy")
+async def api_check_proxy(req: ProxyCheckRequest) -> Dict[str, Any]:
+ """检测代理是否可用(通过 Cloudflare Trace)"""
+ def _check() -> Dict[str, Any]:
+ proxy = req.proxy.strip()
+ try:
+ from curl_cffi import requests as cffi_req
+ import re
+
+ proxies = {"http": proxy, "https": proxy} if proxy else None
+ try:
+ resp = cffi_req.get(
+ "https://cloudflare.com/cdn-cgi/trace",
+ proxies=proxies,
+ http_version="v2",
+ impersonate="chrome",
+ timeout=8,
+ )
+ except Exception as exc:
+ if "HTTP/3 is not supported over an HTTP proxy" not in str(exc):
+ raise
+ resp = cffi_req.get(
+ "https://cloudflare.com/cdn-cgi/trace",
+ proxies=proxies,
+ http_version="v1",
+ impersonate="chrome",
+ timeout=8,
+ )
+ text = resp.text
+ loc_m = re.search(r"^loc=(.+)$", text, re.MULTILINE)
+ loc = loc_m.group(1) if loc_m else "?"
+ supported = loc not in ("CN", "HK")
+ return {"ok": supported, "loc": loc, "error": None if supported else "所在地不支持"}
+ except Exception as e:
+ return {"ok": False, "loc": None, "error": str(e)}
+
+ return await run_in_threadpool(_check)
+
+
+@app.post("/api/proxy-pool/test")
+async def api_proxy_pool_test(req: ProxyPoolTestRequest) -> Dict[str, Any]:
+ """测试代理池取号:返回取到的代理与可选 loc 探测结果"""
+ def _test() -> Dict[str, Any]:
+ cfg_snapshot = _get_sync_config()
+ auth_mode = str(req.auth_mode or "query").strip().lower()
+ if auth_mode not in ("header", "query"):
+ auth_mode = "query"
+ api_url = str(req.api_url or "https://zenproxy.top/api/fetch").strip() or "https://zenproxy.top/api/fetch"
+ api_key = req.api_key.strip() if req.api_key else str(cfg_snapshot.get("proxy_pool_api_key", "")).strip()
+ try:
+ count = max(1, min(int(req.count or cfg_snapshot.get("proxy_pool_count", 1)), 20))
+ except (TypeError, ValueError):
+ count = 1
+ country = str(req.country or cfg_snapshot.get("proxy_pool_country", "US") or "US").strip().upper() or "US"
+
+ cfg = {
+ "enabled": bool(req.enabled),
+ "api_url": api_url,
+ "auth_mode": auth_mode,
+ "api_key": api_key,
+ "count": count,
+ "country": country,
+ "timeout_seconds": 10,
+ }
+ if not cfg["enabled"]:
+ return {"ok": False, "error": "代理池未启用"}
+ if not cfg["api_key"]:
+ return {"ok": False, "error": "API Key 为空"}
+
+ try:
+ from curl_cffi import requests as cffi_req
+ import re
+
+ relay_url = _pool_relay_url_from_fetch_url(api_url)
+ if relay_url:
+ relay_params = {"api_key": api_key, "url": "https://cloudflare.com/cdn-cgi/trace", "country": country}
+ try:
+ relay_resp = cffi_req.get(relay_url, params=relay_params, http_version="v2", impersonate="chrome", timeout=8)
+ except Exception as exc:
+ if "HTTP/3 is not supported over an HTTP proxy" not in str(exc):
+ raise
+ relay_resp = cffi_req.get(relay_url, params=relay_params, http_version="v1", impersonate="chrome", timeout=8)
+ if relay_resp.status_code == 200:
+ relay_text = relay_resp.text
+ relay_loc_m = re.search(r"^loc=(.+)$", relay_text, re.MULTILINE)
+ relay_loc = relay_loc_m.group(1) if relay_loc_m else "?"
+ relay_supported = relay_loc not in ("CN", "HK")
+ return {
+ "ok": True,
+ "proxy": "(relay)",
+ "relay_used": True,
+ "relay_url": relay_url,
+ "count": count,
+ "country": country,
+ "loc": relay_loc,
+ "supported": relay_supported,
+ "trace_error": None,
+ }
+
+ proxy = _fetch_proxy_from_pool(cfg)
+ proxies = {"http": proxy, "https": proxy} if proxy else None
+ trace_error = ""
+ loc = None
+ supported = None
+ try:
+ try:
+ resp = cffi_req.get("https://cloudflare.com/cdn-cgi/trace", proxies=proxies, http_version="v2", impersonate="chrome", timeout=8)
+ except Exception as exc:
+ if "HTTP/3 is not supported over an HTTP proxy" not in str(exc):
+ raise
+ resp = cffi_req.get("https://cloudflare.com/cdn-cgi/trace", proxies=proxies, http_version="v1", impersonate="chrome", timeout=8)
+ text = resp.text
+ loc_m = re.search(r"^loc=(.+)$", text, re.MULTILINE)
+ loc = loc_m.group(1) if loc_m else "?"
+ supported = loc not in ("CN", "HK")
+ except Exception as e:
+ trace_error = str(e)
+
+ return {
+ "ok": True,
+ "proxy": proxy,
+ "relay_used": False,
+ "count": count,
+ "country": country,
+ "loc": loc,
+ "supported": supported,
+ "trace_error": trace_error or None,
+ }
+ except Exception as e:
+ return {"ok": False, "error": str(e)}
+
+ return await run_in_threadpool(_test)
+
+
+@app.get("/api/logs")
+async def api_logs(request: Request) -> StreamingResponse:
+ """SSE 实时结构化事件流"""
+
+ async def event_generator() -> AsyncGenerator[str, None]:
+ q = _state.subscribe()
+ last_heartbeat = time.monotonic()
+ try:
+ snapshot = _state.get_status_snapshot()
+ connected = {
+ "type": "connected",
+ "message": "日志连接成功",
+ "run_id": snapshot["task"].get("run_id"),
+ "revision": snapshot["task"].get("revision", 0),
+ "snapshot": snapshot,
+ }
+ yield f"event: connected\ndata: {json.dumps(connected, ensure_ascii=False)}\n\n"
+ while True:
+ if _service_shutdown_event.is_set():
+ break
+ if await request.is_disconnected():
+ break
+ try:
+ event = await asyncio.wait_for(q.get(), timeout=1.0)
+ event_type = str(event.get("type") or "message")
+ yield f"event: {event_type}\ndata: {json.dumps(event, ensure_ascii=False)}\n\n"
+ if _service_shutdown_event.is_set() or str(event.get("step") or "").strip().lower() == "shutdown":
+ break
+ except asyncio.TimeoutError:
+ if _service_shutdown_event.is_set():
+ break
+ now = time.monotonic()
+ if now - last_heartbeat >= 15:
+ last_heartbeat = now
+ heartbeat = {
+ "type": "heartbeat",
+ "run_id": _state.run_id,
+ "revision": _state.revision,
+ "server_time": datetime.now().isoformat(timespec="seconds"),
+ }
+ yield f"event: heartbeat\ndata: {json.dumps(heartbeat, ensure_ascii=False)}\n\n"
+ except asyncio.CancelledError:
+ break
+ except Exception:
+ break
+ finally:
+ _state.unsubscribe(q)
+
+ return StreamingResponse(
+ event_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+
+class BatchSyncRequest(BaseModel):
+ filenames: List[str] = [] # 空列表 = 同步全部
+
+
+def _decode_jwt_payload(token: str) -> Dict[str, Any]:
+ """解析 JWT payload(不验签)"""
+ try:
+ parts = token.split(".")
+ if len(parts) != 3:
+ return {}
+ payload = parts[1]
+ pad = 4 - len(payload) % 4
+ if pad != 4:
+ payload += "=" * pad
+ import base64 as _b64
+ decoded = _b64.urlsafe_b64decode(payload.encode("ascii"))
+ return json.loads(decoded.decode("utf-8"))
+ except Exception:
+ return {}
+
+
+def _build_account_payload(email: str, token_data: Dict[str, Any]) -> Dict[str, Any]:
+ """参考 chatgpt_register.py 构建 /api/v1/admin/accounts 所需 payload"""
+ access_token = token_data.get("access_token", "")
+ refresh_token = token_data.get("refresh_token", "")
+ id_token = token_data.get("id_token", "")
+
+ at_payload = _decode_jwt_payload(access_token) if access_token else {}
+ at_auth = at_payload.get("https://api.openai.com/auth") or {}
+ chatgpt_account_id = at_auth.get("chatgpt_account_id", "") or token_data.get("account_id", "")
+ chatgpt_user_id = at_auth.get("chatgpt_user_id", "")
+ exp_timestamp = at_payload.get("exp", 0)
+ expires_at = exp_timestamp if isinstance(exp_timestamp, int) and exp_timestamp > 0 else int(time.time()) + 863999
+
+ it_payload = _decode_jwt_payload(id_token) if id_token else {}
+ it_auth = it_payload.get("https://api.openai.com/auth") or {}
+ organization_id = it_auth.get("organization_id", "")
+ if not organization_id:
+ orgs = it_auth.get("organizations") or []
+ if orgs:
+ organization_id = (orgs[0] or {}).get("id", "")
+
+ return {
+ "name": email,
+ "notes": "",
+ "platform": "openai",
+ "type": "oauth",
+ "credentials": {
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "expires_in": 863999,
+ "expires_at": expires_at,
+ "chatgpt_account_id": chatgpt_account_id,
+ "chatgpt_user_id": chatgpt_user_id,
+ "organization_id": organization_id,
+ },
+ "extra": {"email": email},
+ "proxy_id": None,
+ "concurrency": 10,
+ "priority": 1,
+ "rate_multiplier": 1,
+ "group_ids": [2, 4],
+ "expires_at": None,
+ "auto_pause_on_expired": True,
+ }
+
+
+def _push_account_api(base_url: str, bearer: str, email: str, token_data: Dict[str, Any]) -> Dict[str, Any]:
+ """调用 /api/v1/admin/accounts 提交完整账号信息"""
+ from curl_cffi import requests as cffi_req
+ url = base_url.rstrip("/") + "/api/v1/admin/accounts"
+ payload = _build_account_payload(email, token_data)
+ try:
+ resp = cffi_req.post(
+ url,
+ json=payload,
+ headers={
+ "Authorization": f"Bearer {bearer}",
+ "Content-Type": "application/json",
+ "Accept": "application/json, text/plain, */*",
+ "Referer": base_url.rstrip("/") + "/admin/accounts",
+ },
+ impersonate="chrome",
+ timeout=20,
+ )
+ return {"ok": resp.status_code in (200, 201), "status": resp.status_code, "body": resp.text[:300]}
+ except Exception as e:
+ return {"ok": False, "status": 0, "body": str(e)}
+
+
+def _update_sub2api_account_api(
+ base_url: str,
+ bearer: str,
+ account_id: int,
+ email: str,
+ token_data: Dict[str, Any],
+) -> Dict[str, Any]:
+ """
+ 命中已存在账号后,更新其凭据,避免“存在即跳过”导致账号长期不刷新。
+ """
+ from curl_cffi import requests as cffi_req
+
+ url = base_url.rstrip("/") + f"/api/v1/admin/accounts/{int(account_id)}"
+ create_payload = _build_account_payload(email, token_data)
+ credentials = create_payload.get("credentials") if isinstance(create_payload.get("credentials"), dict) else {}
+ extra = create_payload.get("extra") if isinstance(create_payload.get("extra"), dict) else {}
+ payload = {
+ "name": str(email or "").strip(),
+ "credentials": credentials,
+ "extra": extra,
+ "concurrency": create_payload.get("concurrency", 10),
+ "priority": create_payload.get("priority", 1),
+ "status": "active",
+ "auto_pause_on_expired": True,
+ }
+ try:
+ resp = cffi_req.put(
+ url,
+ json=payload,
+ headers={
+ "Authorization": f"Bearer {bearer}",
+ "Content-Type": "application/json",
+ "Accept": "application/json, text/plain, */*",
+ "Referer": base_url.rstrip("/") + "/admin/accounts",
+ },
+ impersonate="chrome",
+ timeout=20,
+ )
+ return {"ok": resp.status_code in (200, 201), "status": resp.status_code, "body": resp.text[:300]}
+ except Exception as e:
+ return {"ok": False, "status": 0, "body": str(e)}
+
+
+def _extract_sub2api_page_payload(body: Any) -> Dict[str, Any]:
+ if isinstance(body, dict):
+ data = body.get("data")
+ if isinstance(data, dict):
+ return data
+ return body
+ return {}
+
+
+def _sub2api_identity_keys(email: str, refresh_token: str) -> List[str]:
+ keys: List[str] = []
+ email_norm = str(email or "").strip().lower()
+ refresh_token_norm = str(refresh_token or "").strip()
+ if email_norm:
+ keys.append(f"email:{email_norm}")
+ if refresh_token_norm:
+ keys.append(f"rt:{refresh_token_norm}")
+ return keys
+
+
+def _load_local_token_identity_keys(max_files: int = 20000) -> set[str]:
+ """
+ 预加载本地 token 文件身份键,用于运行前去重(跨线程/跨重启防重复落盘)。
+ """
+ keys: set[str] = set()
+ if not os.path.isdir(TOKENS_DIR):
+ return keys
+
+ loaded = 0
+ for fname in os.listdir(TOKENS_DIR):
+ if loaded >= max_files:
+ break
+ if not str(fname).endswith(".json"):
+ continue
+ fpath = os.path.join(TOKENS_DIR, fname)
+ if not os.path.isfile(fpath):
+ continue
+ try:
+ with open(fpath, "r", encoding="utf-8") as f:
+ td = json.load(f)
+ if not isinstance(td, dict):
+ continue
+ email = str(td.get("email") or "").strip()
+ refresh_token = str(td.get("refresh_token") or "").strip()
+ keys.update(_sub2api_identity_keys(email, refresh_token))
+ loaded += 1
+ except Exception:
+ continue
+ return keys
+
+
+def _sub2api_item_matches_identity(item: Dict[str, Any], email: str, refresh_token: str) -> bool:
+ email_norm = str(email or "").strip().lower()
+ refresh_token_norm = str(refresh_token or "").strip()
+
+ name = str(item.get("name") or "").strip().lower()
+ extra = item.get("extra") if isinstance(item.get("extra"), dict) else {}
+ credentials = item.get("credentials") if isinstance(item.get("credentials"), dict) else {}
+ item_email = str(extra.get("email") or "").strip().lower()
+ item_refresh_token = str(credentials.get("refresh_token") or "").strip()
+
+ if refresh_token_norm and item_refresh_token and item_refresh_token == refresh_token_norm:
+ return True
+ if email_norm and (name == email_norm or item_email == email_norm):
+ return True
+ return False
+
+
+def _find_existing_sub2api_account(
+ base_url: str,
+ bearer: str,
+ email: str,
+ refresh_token: str,
+ max_pages: int = 8,
+) -> Optional[Dict[str, Any]]:
+ """
+ 在 Sub2Api 端查找是否已存在同一身份账号(email / refresh_token)。
+ 说明:
+ - 主查 email(search 参数),并在返回项里再次精确匹配;
+ - 若首次未命中,且提供了 refresh_token,会在有限页内继续扫一遍做 token 精确匹配。
+ """
+ from curl_cffi import requests as cffi_req
+
+ url = base_url.rstrip("/") + "/api/v1/admin/accounts"
+ email_norm = str(email or "").strip().lower()
+ refresh_token_norm = str(refresh_token or "").strip()
+ if not email_norm and not refresh_token_norm:
+ return None
+
+ headers = {
+ "Authorization": f"Bearer {bearer}",
+ "Accept": "application/json, text/plain, */*",
+ }
+
+ page_size = 100
+ page = 1
+ scanned_without_search = 0
+
+ while page <= max_pages:
+ params: Dict[str, Any] = {
+ "page": page,
+ "page_size": page_size,
+ "platform": "openai",
+ "type": "oauth",
+ }
+ if email_norm:
+ params["search"] = email_norm
+
+ try:
+ resp = cffi_req.get(
+ url,
+ params=params,
+ headers=headers,
+ impersonate="chrome",
+ timeout=15,
+ )
+ if resp.status_code != 200:
+ return None
+ body = resp.json()
+ except Exception:
+ return None
+
+ data = _extract_sub2api_page_payload(body)
+ items = data.get("items") if isinstance(data.get("items"), list) else []
+ for item in items:
+ if isinstance(item, dict) and _sub2api_item_matches_identity(item, email_norm, refresh_token_norm):
+ return item
+
+ total_raw = data.get("total")
+ try:
+ total = int(total_raw) if total_raw is not None else 0
+ except (TypeError, ValueError):
+ total = 0
+ if len(items) < page_size or (total > 0 and page * page_size >= total):
+ break
+ page += 1
+
+ # search=xxx 未命中时,额外做有限页扫描,用 refresh_token 做兜底精确匹配
+ if refresh_token_norm:
+ page = 1
+ while page <= 3:
+ params = {
+ "page": page,
+ "page_size": page_size,
+ "platform": "openai",
+ "type": "oauth",
+ }
+ try:
+ resp = cffi_req.get(
+ url,
+ params=params,
+ headers=headers,
+ impersonate="chrome",
+ timeout=15,
+ )
+ if resp.status_code != 200:
+ return None
+ body = resp.json()
+ except Exception:
+ return None
+
+ data = _extract_sub2api_page_payload(body)
+ items = data.get("items") if isinstance(data.get("items"), list) else []
+ for item in items:
+ if isinstance(item, dict) and _sub2api_item_matches_identity(item, "", refresh_token_norm):
+ return item
+
+ scanned_without_search += len(items)
+ if len(items) < page_size or scanned_without_search >= 300:
+ break
+ page += 1
+
+ return None
+
+
+def _push_account_api_with_dedupe(
+ base_url: str,
+ bearer: str,
+ email: str,
+ token_data: Dict[str, Any],
+ check_before: bool = True,
+ check_after: bool = True,
+) -> Dict[str, Any]:
+ """
+ 上传前后做远端查重,避免重复创建同一账号。
+ 返回结构兼容 _push_account_api,额外包含:
+ - skipped: bool
+ - reason: str
+ - existing_id: Optional[int]
+ """
+ refresh_token = str(token_data.get("refresh_token") or "").strip()
+ existing: Optional[Dict[str, Any]] = None
+
+ if check_before:
+ existing = _find_existing_sub2api_account(base_url, bearer, email, refresh_token)
+ if existing is not None:
+ existing_id = existing.get("id")
+ existing_int = None
+ try:
+ existing_int = int(existing_id)
+ except (TypeError, ValueError):
+ existing_int = None
+ if existing_int is not None and existing_int > 0:
+ update_result = _update_sub2api_account_api(
+ base_url=base_url,
+ bearer=bearer,
+ account_id=existing_int,
+ email=email,
+ token_data=token_data,
+ )
+ if update_result.get("ok"):
+ return {
+ "ok": True,
+ "status": int(update_result.get("status") or 200),
+ "body": "existing account updated",
+ "skipped": False,
+ "reason": "updated_existing_before_create",
+ "existing_id": existing_int,
+ }
+ return {
+ "ok": False,
+ "status": int(update_result.get("status") or 0),
+ "body": "existing account update failed",
+ "skipped": False,
+ "reason": "exists_before_create_update_failed",
+ "existing_id": existing_int,
+ "update_status": int(update_result.get("status") or 0),
+ "update_body": str(update_result.get("body") or "")[:240],
+ }
+ return {
+ "ok": True,
+ "status": 200,
+ "body": "account already exists",
+ "skipped": True,
+ "reason": "exists_before_create",
+ "existing_id": existing_id,
+ }
+
+ result = _push_account_api(base_url, bearer, email, token_data)
+ if result.get("ok"):
+ result["skipped"] = False
+ return result
+
+ if check_after:
+ existing = _find_existing_sub2api_account(base_url, bearer, email, refresh_token)
+ if existing is not None:
+ return {
+ "ok": True,
+ "status": int(result.get("status") or 200),
+ "body": "request failed but account exists",
+ "skipped": True,
+ "reason": "exists_after_create",
+ "existing_id": existing.get("id"),
+ }
+
+ result.setdefault("skipped", False)
+ return result
+
+
+@app.post("/api/sync-batch")
+async def api_sync_batch(req: BatchSyncRequest) -> Dict[str, Any]:
+ """通过 HTTP API 将本地 Token 批量导入 Sub2Api 平台"""
+ def _sync_batch() -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ base_url = str(cfg.get("base_url", "") or "").strip()
+ bearer = str(cfg.get("bearer_token", "") or "").strip()
+
+ if not base_url:
+ raise HTTPException(status_code=400, detail="请先配置 Sub2Api 平台地址")
+ if not bearer:
+ raise HTTPException(status_code=400, detail="Bearer Token 为空,请重新保存配置以自动登录获取")
+
+ fnames = list(req.filenames or [])
+ if not fnames:
+ fnames = [f for f in os.listdir(TOKENS_DIR) if f.endswith(".json")]
+
+ results = []
+ for fname in fnames:
+ if "/" in fname or "\\" in fname or ".." in fname:
+ continue
+ fpath = os.path.join(TOKENS_DIR, fname)
+ if not os.path.isfile(fpath):
+ results.append({"file": fname, "ok": False, "error": "文件不存在"})
+ continue
+ try:
+ with open(fpath, "r", encoding="utf-8") as f:
+ token_data = json.load(f)
+ email = token_data.get("email", fname)
+ if _is_sub2api_uploaded(token_data):
+ results.append({"file": fname, "email": email, "ok": True, "skipped": True})
+ continue
+ result = _push_account_api_with_dedupe(
+ base_url=base_url,
+ bearer=bearer,
+ email=str(email),
+ token_data=token_data,
+ check_before=True,
+ check_after=True,
+ )
+ results.append({"file": fname, "email": email, **result})
+ if result["ok"]:
+ _mark_token_uploaded_platform(fpath, "sub2api")
+ reason = str(result.get("reason") or "")
+ if reason == "updated_existing_before_create":
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "success",
+ "message": f"[API] {email}: 命中已存在账号并更新凭据 (id={result.get('existing_id', '-')})",
+ "step": "sync",
+ })
+ elif result.get("skipped"):
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "success",
+ "message": f"[API] {email}: 同步成功",
+ "step": "sync",
+ })
+ else:
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "success",
+ "message": f"[API] {email}: 导入成功",
+ "step": "sync",
+ })
+ else:
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "error",
+ "message": f"[API] {email}: 导入失败({result['status']}) {result['body'][:100]}",
+ "step": "sync",
+ })
+ except Exception as e:
+ results.append({"file": fname, "ok": False, "error": str(e)})
+
+ ok_count = sum(1 for r in results if r.get("ok") and not r.get("skipped"))
+ skip_count = sum(1 for r in results if r.get("skipped"))
+ fail_count = sum(1 for r in results if not r.get("ok"))
+ return {"total": len(results), "ok": ok_count, "skipped": skip_count, "fail": fail_count, "results": results}
+
+ return await run_in_threadpool(_sync_batch)
+
+
+# ==========================================
+# Pool / Mail 配置 & 维护 API
+# ==========================================
+
+
+class PoolConfigRequest(BaseModel):
+ cpa_base_url: str = ""
+ cpa_token: str = ""
+ min_candidates: int = 800
+ used_percent_threshold: int = 95
+ auto_maintain: bool = False
+ maintain_interval_minutes: int = 30
+
+
+class MailConfigRequest(BaseModel):
+ mail_provider: str = "mailtm"
+ mail_config: Dict[str, str] = {}
+ mail_providers: List[str] = []
+ mail_provider_configs: Dict[str, Dict[str, str]] = {}
+ mail_strategy: str = "round_robin"
+
+
+@app.get("/api/pool/config")
+async def api_get_pool_config() -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ token = str(cfg.get("cpa_token", ""))
+ return {
+ "cpa_base_url": cfg.get("cpa_base_url", ""),
+ "cpa_token_preview": (token[:12] + "...") if len(token) > 12 else token,
+ "min_candidates": cfg.get("min_candidates", 800),
+ "used_percent_threshold": cfg.get("used_percent_threshold", 95),
+ "auto_maintain": cfg.get("auto_maintain", False),
+ "maintain_interval_minutes": cfg.get("maintain_interval_minutes", 30),
+ }
+
+
+@app.post("/api/pool/config")
+async def api_set_pool_config(req: PoolConfigRequest) -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ cfg["cpa_base_url"] = req.cpa_base_url.strip()
+ cfg["cpa_token"] = req.cpa_token.strip() or str(cfg.get("cpa_token", "") or "").strip()
+ cfg["min_candidates"] = req.min_candidates
+ cfg["used_percent_threshold"] = req.used_percent_threshold
+ cfg["auto_maintain"] = req.auto_maintain
+ cfg["maintain_interval_minutes"] = max(5, req.maintain_interval_minutes)
+ _save_sync_config(cfg)
+
+ # 启停自动维护
+ if req.auto_maintain:
+ _start_auto_maintain()
+ else:
+ _stop_auto_maintain()
+
+ return {"status": "saved"}
+
+
+@app.get("/api/pool/status")
+async def api_pool_status() -> Dict[str, Any]:
+ pm = _get_pool_maintainer()
+ if not pm:
+ return {"configured": False, "error": "CPA 未配置"}
+ status = await run_in_threadpool(pm.get_pool_status)
+ status["configured"] = True
+ return status
+
+
+@app.post("/api/pool/check")
+async def api_pool_check() -> Dict[str, Any]:
+ pm = _get_pool_maintainer()
+ if not pm:
+ raise HTTPException(status_code=400, detail="CPA 未配置")
+ result = await run_in_threadpool(pm.test_connection)
+ return result
+
+
+@app.post("/api/pool/maintain")
+async def api_pool_maintain() -> Dict[str, Any]:
+ pm = _get_pool_maintainer()
+ if not pm:
+ raise HTTPException(status_code=400, detail="CPA 未配置")
+ if not _pool_maintain_lock.acquire(blocking=False):
+ raise HTTPException(status_code=409, detail="维护任务已在执行中")
+ try:
+ result = await run_in_threadpool(pm.probe_and_clean_sync)
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "info",
+ "message": f"[POOL] 维护完成: 无效 {result.get('invalid_count', 0)}, 已删除 {result.get('deleted_ok', 0)}",
+ "step": "pool_maintain",
+ })
+ return result
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ _pool_maintain_lock.release()
+
+
+@app.post("/api/pool/auto")
+async def api_pool_auto(enable: bool = True) -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ cfg["auto_maintain"] = enable
+ _save_sync_config(cfg)
+ if enable:
+ _start_auto_maintain()
+ else:
+ _stop_auto_maintain()
+ return {"auto_maintain": enable}
+
+
+@app.get("/api/mail/config")
+async def api_get_mail_config() -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ # 兼容旧格式
+ mail_cfg = dict(cfg.get("mail_config") or {})
+ token = str(mail_cfg.get("bearer_token", ""))
+ mail_cfg["bearer_token_preview"] = (token[:12] + "...") if len(token) > 12 else token
+ mail_cfg.pop("bearer_token", None)
+ key = str(mail_cfg.get("api_key", ""))
+ mail_cfg["api_key_preview"] = (key[:8] + "...") if len(key) > 8 else key
+ mail_cfg.pop("api_key", None)
+
+ # 脱敏 provider_configs 中的敏感字段
+ raw_configs = cfg.get("mail_provider_configs") or {}
+ safe_configs: Dict[str, Dict] = {}
+ for pname, pcfg in raw_configs.items():
+ sc = dict(pcfg)
+ for secret_key in ("bearer_token", "api_key", "admin_password"):
+ val = str(sc.get(secret_key, ""))
+ if val:
+ sc[f"{secret_key}_preview"] = (val[:8] + "...") if len(val) > 8 else val
+ sc.pop(secret_key, None)
+ safe_configs[pname] = sc
+
+ return {
+ "mail_provider": cfg.get("mail_provider", "mailtm"),
+ "mail_config": mail_cfg,
+ "mail_providers": cfg.get("mail_providers", []),
+ "mail_provider_configs": safe_configs,
+ "mail_strategy": cfg.get("mail_strategy", "round_robin"),
+ }
+
+
+@app.post("/api/mail/config")
+async def api_set_mail_config(req: MailConfigRequest) -> Dict[str, Any]:
+ cfg = _get_sync_config()
+ # 兼容旧格式
+ cfg["mail_provider"] = req.mail_provider.strip() or "mailtm"
+ cfg["mail_config"] = {str(k): str(v).strip() for k, v in (req.mail_config or {}).items()}
+
+ # 新多提供商格式
+ if req.mail_providers:
+ cfg["mail_providers"] = [str(name).strip().lower() for name in req.mail_providers if str(name).strip()]
+ cfg["mail_strategy"] = req.mail_strategy or "round_robin"
+
+ existing_configs = cfg.get("mail_provider_configs") or {}
+ for pname, pcfg in req.mail_provider_configs.items():
+ existing_configs[str(pname).strip().lower()] = {
+ str(k): str(v).strip() for k, v in (pcfg or {}).items()
+ }
+ cfg["mail_provider_configs"] = existing_configs
+
+ _save_sync_config(cfg)
+ return {"status": "saved"}
+
+
+@app.post("/api/mail/test")
+async def api_mail_test() -> Dict[str, Any]:
+ try:
+ cfg = _get_sync_config()
+ router = MultiMailRouter(cfg)
+ results = []
+ proxy = str(cfg.get("proxy") or _state.current_proxy or "").strip()
+ for pname, provider in router.providers():
+ ok, msg = await run_in_threadpool(provider.test_connection, proxy)
+ results.append({"provider": pname, "ok": ok, "message": msg})
+ all_ok = all(r["ok"] for r in results)
+ return {"ok": all_ok, "results": results, "message": "全部通过" if all_ok else "部分失败"}
+ except Exception as e:
+ return {"ok": False, "message": str(e)}
+
+
+def _try_auto_register() -> None:
+ """维护后检查池状态,若不足则自动启动注册补充"""
+ ts = datetime.now().strftime("%H:%M:%S")
+ cfg = _get_sync_config()
+ if not cfg.get("auto_register"):
+ _state.broadcast({
+ "ts": ts, "level": "info",
+ "message": "[AUTO] 自动注册未开启,跳过(请勾选「池不足自动注册」并保存代理)",
+ "step": "auto_register",
+ })
+ return
+ proxy = str(cfg.get("proxy", "") or "").strip()
+ proxy_pool_enabled = bool(cfg.get("proxy_pool_enabled", False))
+ if not proxy and not proxy_pool_enabled:
+ _state.broadcast({
+ "ts": ts, "level": "warn",
+ "message": "[AUTO] 跳过自动注册:未配置固定代理且代理池未启用,请先配置",
+ "step": "auto_register",
+ })
+ return
+ if _state.status != "idle":
+ _state.broadcast({
+ "ts": ts, "level": "info",
+ "message": f"[AUTO] 跳过自动注册:当前状态 {_state.status}",
+ "step": "auto_register",
+ })
+ return
+ upload_mode = str(cfg.get("upload_mode", "snapshot") or "snapshot").strip().lower()
+ if upload_mode not in ("snapshot", "decoupled"):
+ upload_mode = "snapshot"
+ gap = 0
+ cpa_gap = 0
+ sub2api_gap = 0
+ api_error = False
+ pm = _get_pool_maintainer(cfg)
+ if pm:
+ try:
+ cpa_gap = pm.calculate_gap()
+ except Exception as e:
+ api_error = True
+ _state.broadcast({
+ "ts": ts, "level": "warn",
+ "message": f"[AUTO] CPA 池状态查询失败,稍后重试: {e}",
+ "step": "auto_register",
+ })
+ sm = _get_sub2api_maintainer(cfg)
+ if sm and _is_auto_sync_enabled(cfg):
+ try:
+ sub2api_gap = sm.calculate_gap()
+ except Exception as e:
+ api_error = True
+ _state.broadcast({
+ "ts": ts, "level": "warn",
+ "message": f"[AUTO] Sub2Api 池状态查询失败,稍后重试: {e}",
+ "step": "auto_register",
+ })
+ elif sm:
+ _state.broadcast({
+ "ts": ts, "level": "info",
+ "message": "[AUTO] Sub2Api 自动同步未开启,自动补号仅按 CPA 缺口执行",
+ "step": "auto_register",
+ })
+ gap = (cpa_gap + sub2api_gap) if upload_mode == "snapshot" else max(cpa_gap, sub2api_gap)
+ if api_error and gap <= 0:
+ return
+ if gap <= 0:
+ _state.broadcast({
+ "ts": ts, "level": "info",
+ "message": "[AUTO] 池已充足,无需补充注册",
+ "step": "auto_register",
+ })
+ return
+ multithread = bool(cfg.get("multithread", False))
+ thread_count = int(cfg.get("thread_count", 3))
+ try:
+ _state.start_task(
+ proxy,
+ worker_count=thread_count if multithread else 1,
+ target_count=gap,
+ cpa_target_count=cpa_gap if pm else 0,
+ sub2api_target_count=sub2api_gap if sm and _is_auto_sync_enabled(cfg) else 0,
+ )
+ _state.broadcast({
+ "ts": ts, "level": "success",
+ "message": (
+ f"[AUTO] 自动注册已启动:总补充 {gap}(CPA 缺口 {cpa_gap} / Sub2Api 缺口 {sub2api_gap} / "
+ f"策略 {upload_mode})"
+ ),
+ "step": "auto_register",
+ })
+ except RuntimeError as e:
+ _state.broadcast({
+ "ts": ts, "level": "warn",
+ "message": f"[AUTO] 自动注册启动失败:{e}",
+ "step": "auto_register",
+ })
+
+
+def _start_auto_maintain() -> None:
+ global _auto_maintain_thread, _auto_maintain_stop
+ cfg = _get_sync_config()
+ interval = max(5, int(cfg.get("maintain_interval_minutes", 30))) * 60
+ with _auto_maintain_ctl_lock:
+ if _auto_maintain_thread and _auto_maintain_thread.is_alive():
+ return
+ stop_event = threading.Event()
+ _auto_maintain_stop = stop_event
+
+ def _loop(local_stop: threading.Event) -> None:
+ while not local_stop.is_set():
+ pm = _get_pool_maintainer()
+ if pm:
+ if not _pool_maintain_lock.acquire(blocking=False):
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "warn",
+ "message": "[POOL] 跳过自动维护:已有维护任务在执行",
+ "step": "pool_auto",
+ })
+ else:
+ try:
+ result = pm.probe_and_clean_sync()
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "info",
+ "message": f"[POOL] 自动维护: 无效 {result.get('invalid_count', 0)}, 已删除 {result.get('deleted_ok', 0)}",
+ "step": "pool_auto",
+ })
+ except Exception as e:
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "error",
+ "message": f"[POOL] 自动维护异常: {e}",
+ "step": "pool_auto",
+ })
+ finally:
+ _pool_maintain_lock.release()
+ _try_auto_register()
+ local_stop.wait(interval)
+
+ thread = threading.Thread(target=_loop, args=(stop_event,), daemon=True)
+ with _auto_maintain_ctl_lock:
+ _auto_maintain_thread = thread
+ thread.start()
+
+
+def _stop_auto_maintain() -> None:
+ global _auto_maintain_thread, _auto_maintain_stop
+ with _auto_maintain_ctl_lock:
+ stop_event = _auto_maintain_stop
+ thread = _auto_maintain_thread
+ if stop_event:
+ stop_event.set()
+ if thread and thread.is_alive():
+ thread.join(timeout=5)
+ with _auto_maintain_ctl_lock:
+ if _auto_maintain_thread is thread and (thread is None or not thread.is_alive()):
+ _auto_maintain_thread = None
+ _auto_maintain_stop = None
+
+
+# ==========================================
+# Sub2Api 池维护 API & 自动维护
+# ==========================================
+
+_sub2api_auto_maintain_thread: Optional[threading.Thread] = None
+_sub2api_auto_maintain_stop: Optional[threading.Event] = None
+_sub2api_auto_maintain_ctl_lock = threading.Lock()
+_sub2api_maintain_lock = threading.Lock()
+
+
+class Sub2ApiDedupeRequest(BaseModel):
+ dry_run: bool = True
+ timeout: int = 20
+
+
+class Sub2ApiAccountActionRequest(BaseModel):
+ account_ids: List[int] = Field(default_factory=list)
+ timeout: int = 30
+
+
+class Sub2ApiExceptionHandleRequest(Sub2ApiAccountActionRequest):
+ delete_unresolved: bool = True
+
+
+@app.get("/api/sub2api/accounts")
+async def api_sub2api_accounts(
+ page: int = 1,
+ page_size: int = 20,
+ status: str = "all",
+ keyword: str = "",
+) -> Dict[str, Any]:
+ sm = _get_sub2api_maintainer()
+ if not sm:
+ return {"configured": False, "error": "Sub2Api 未配置", "items": []}
+ cfg = _get_sync_config()
+ snapshot = await run_in_threadpool(
+ lambda: _get_sub2api_accounts_inventory_snapshot(sm, cfg)
+ )
+ filtered_items = _filter_sub2api_account_items(
+ list(snapshot.get("items") or []),
+ status=status,
+ keyword=keyword,
+ )
+ paged = _paginate_sub2api_account_items(filtered_items, page=page, page_size=page_size)
+ return {
+ "configured": True,
+ "total": int(snapshot.get("total", 0)),
+ "error_count": int(snapshot.get("error_count", 0)),
+ "duplicate_groups": int(snapshot.get("duplicate_groups", 0)),
+ "duplicate_accounts": int(snapshot.get("duplicate_accounts", 0)),
+ "items": paged["items"],
+ "page": paged["page"],
+ "page_size": paged["page_size"],
+ "filtered_total": paged["filtered_total"],
+ "total_pages": paged["total_pages"],
+ "status": str(status or "all"),
+ "keyword": str(keyword or ""),
+ }
+
+
+@app.post("/api/sub2api/accounts/probe")
+async def api_sub2api_accounts_probe(req: Sub2ApiAccountActionRequest) -> Dict[str, Any]:
+ sm = _get_sub2api_maintainer()
+ if not sm:
+ raise HTTPException(status_code=400, detail="Sub2Api 未配置")
+ if not req.account_ids:
+ raise HTTPException(status_code=400, detail="请先选择至少一个账号")
+ if not _sub2api_maintain_lock.acquire(blocking=False):
+ raise HTTPException(status_code=409, detail="Sub2Api 账号任务已在执行中")
+ try:
+ timeout = max(5, int(req.timeout))
+ result = await run_in_threadpool(
+ lambda: sm.probe_accounts(req.account_ids, timeout=timeout)
+ )
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "info",
+ "message": (
+ f"[Sub2Api] 账号测活: 请求 {result.get('requested', 0)}, "
+ f"刷新成功 {result.get('refreshed_ok', 0)}, "
+ f"恢复 {result.get('recovered', 0)}, "
+ f"仍异常 {result.get('still_abnormal', 0)}"
+ ),
+ "step": "sub2api_accounts_probe",
+ })
+ _clear_sub2api_accounts_cache()
+ return result
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ _sub2api_maintain_lock.release()
+
+
+@app.post("/api/sub2api/accounts/delete")
+async def api_sub2api_accounts_delete(req: Sub2ApiAccountActionRequest) -> Dict[str, Any]:
+ sm = _get_sub2api_maintainer()
+ if not sm:
+ raise HTTPException(status_code=400, detail="Sub2Api 未配置")
+ if not req.account_ids:
+ raise HTTPException(status_code=400, detail="请先选择至少一个账号")
+ if not _sub2api_maintain_lock.acquire(blocking=False):
+ raise HTTPException(status_code=409, detail="Sub2Api 账号任务已在执行中")
+ try:
+ timeout = max(5, int(req.timeout))
+ result = await run_in_threadpool(
+ lambda: sm.delete_accounts_batch(req.account_ids, timeout=timeout)
+ )
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "info",
+ "message": (
+ f"[Sub2Api] 批量删除: 请求 {result.get('requested', 0)}, "
+ f"删除成功 {result.get('deleted_ok', 0)}, "
+ f"删除失败 {result.get('deleted_fail', 0)}"
+ ),
+ "step": "sub2api_accounts_delete",
+ })
+ _clear_sub2api_accounts_cache()
+ return result
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ _sub2api_maintain_lock.release()
+
+
+@app.post("/api/sub2api/accounts/handle-exception")
+async def api_sub2api_accounts_handle_exception(req: Sub2ApiExceptionHandleRequest) -> Dict[str, Any]:
+ sm = _get_sub2api_maintainer()
+ if not sm:
+ raise HTTPException(status_code=400, detail="Sub2Api 未配置")
+ if not _sub2api_maintain_lock.acquire(blocking=False):
+ raise HTTPException(status_code=409, detail="Sub2Api 账号任务已在执行中")
+ try:
+ timeout = max(5, int(req.timeout))
+ result = await run_in_threadpool(
+ lambda: sm.handle_exception_accounts(
+ req.account_ids or None,
+ timeout=timeout,
+ delete_unresolved=bool(req.delete_unresolved),
+ )
+ )
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "info",
+ "message": (
+ f"[Sub2Api] 异常处理: 目标 {result.get('targeted', 0)}, "
+ f"恢复 {result.get('recovered', 0)}, "
+ f"删除 {result.get('deleted_ok', 0)}(失败 {result.get('deleted_fail', 0)})"
+ ),
+ "step": "sub2api_accounts_exception",
+ })
+ _clear_sub2api_accounts_cache()
+ return result
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ _sub2api_maintain_lock.release()
+
+
+@app.get("/api/sub2api/pool/status")
+async def api_sub2api_pool_status() -> Dict[str, Any]:
+ sm = _get_sub2api_maintainer()
+ if not sm:
+ return {"configured": False, "error": "Sub2Api 未配置"}
+ status = await run_in_threadpool(sm.get_pool_status)
+ status["configured"] = True
+ return status
+
+
+@app.post("/api/sub2api/pool/check")
+async def api_sub2api_pool_check() -> Dict[str, Any]:
+ sm = _get_sub2api_maintainer()
+ if not sm:
+ raise HTTPException(status_code=400, detail="Sub2Api 未配置")
+ result = await run_in_threadpool(sm.test_connection)
+ return result
+
+
+@app.post("/api/sub2api/pool/maintain")
+async def api_sub2api_pool_maintain() -> Dict[str, Any]:
+ sm = _get_sub2api_maintainer()
+ if not sm:
+ raise HTTPException(status_code=400, detail="Sub2Api 未配置")
+ if not _sub2api_maintain_lock.acquire(blocking=False):
+ raise HTTPException(status_code=409, detail="Sub2Api 维护任务已在执行中")
+ try:
+ cfg = _get_sync_config()
+ actions = _get_sub2api_maintain_actions(cfg)
+ result = await run_in_threadpool(
+ lambda: sm.probe_and_clean_sync(actions=actions)
+ )
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "info",
+ "message": _format_sub2api_maintain_result_message(result),
+ "step": "sub2api_maintain",
+ })
+ _clear_sub2api_accounts_cache()
+ return result
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ _sub2api_maintain_lock.release()
+
+
+@app.post("/api/sub2api/pool/dedupe")
+async def api_sub2api_pool_dedupe(req: Sub2ApiDedupeRequest) -> Dict[str, Any]:
+ sm = _get_sub2api_maintainer()
+ if not sm:
+ raise HTTPException(status_code=400, detail="Sub2Api 未配置")
+ if not _sub2api_maintain_lock.acquire(blocking=False):
+ raise HTTPException(status_code=409, detail="Sub2Api 维护任务已在执行中")
+ try:
+ timeout = max(5, int(req.timeout))
+ dry_run = bool(req.dry_run)
+ result = await run_in_threadpool(
+ lambda: sm.dedupe_duplicate_accounts(timeout=timeout, dry_run=dry_run)
+ )
+ if dry_run:
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "info",
+ "message": (
+ f"[Sub2Api] 重复预检完成: 重复组 {result.get('duplicate_groups', 0)}, "
+ f"可删 {result.get('to_delete', 0)}"
+ ),
+ "step": "sub2api_dedupe",
+ })
+ else:
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "info",
+ "message": (
+ f"[Sub2Api] 重复清理完成: 删除成功 {result.get('deleted_ok', 0)}, "
+ f"删除失败 {result.get('deleted_fail', 0)}"
+ ),
+ "step": "sub2api_dedupe",
+ })
+ _clear_sub2api_accounts_cache()
+ return result
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ _sub2api_maintain_lock.release()
+
+
+def _start_sub2api_auto_maintain() -> None:
+ global _sub2api_auto_maintain_thread, _sub2api_auto_maintain_stop
+ cfg = _get_sync_config()
+ interval = max(5, int(cfg.get("sub2api_maintain_interval_minutes", 30))) * 60
+ with _sub2api_auto_maintain_ctl_lock:
+ if _sub2api_auto_maintain_thread and _sub2api_auto_maintain_thread.is_alive():
+ return
+ stop_event = threading.Event()
+ _sub2api_auto_maintain_stop = stop_event
+
+ def _loop(local_stop: threading.Event) -> None:
+ while not local_stop.is_set():
+ sm = _get_sub2api_maintainer()
+ if sm:
+ if not _sub2api_maintain_lock.acquire(blocking=False):
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "warn",
+ "message": "[Sub2Api] 跳过自动维护:已有维护任务在执行",
+ "step": "sub2api_auto",
+ })
+ else:
+ try:
+ current_cfg = _get_sync_config()
+ result = sm.probe_and_clean_sync(
+ actions=_get_sub2api_maintain_actions(current_cfg)
+ )
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "info",
+ "message": _format_sub2api_maintain_result_message(result, auto=True),
+ "step": "sub2api_auto",
+ })
+ _clear_sub2api_accounts_cache()
+ except Exception as e:
+ _state.broadcast({
+ "ts": datetime.now().strftime("%H:%M:%S"),
+ "level": "error",
+ "message": f"[Sub2Api] 自动维护异常: {e}",
+ "step": "sub2api_auto",
+ })
+ finally:
+ _sub2api_maintain_lock.release()
+ _try_auto_register()
+ local_stop.wait(interval)
+
+ thread = threading.Thread(target=_loop, args=(stop_event,), daemon=True)
+ with _sub2api_auto_maintain_ctl_lock:
+ _sub2api_auto_maintain_thread = thread
+ thread.start()
+
+
+def _stop_sub2api_auto_maintain() -> None:
+ global _sub2api_auto_maintain_thread, _sub2api_auto_maintain_stop
+ with _sub2api_auto_maintain_ctl_lock:
+ stop_event = _sub2api_auto_maintain_stop
+ thread = _sub2api_auto_maintain_thread
+ if stop_event:
+ stop_event.set()
+ if thread and thread.is_alive():
+ thread.join(timeout=5)
+ with _sub2api_auto_maintain_ctl_lock:
+ if _sub2api_auto_maintain_thread is thread and (thread is None or not thread.is_alive()):
+ _sub2api_auto_maintain_thread = None
+ _sub2api_auto_maintain_stop = None
+
+
+@app.on_event("startup")
+async def _startup_restore_background_tasks() -> None:
+ _service_shutdown_event.clear()
+ cfg = _get_sync_config()
+ if cfg.get("auto_maintain"):
+ _start_auto_maintain()
+ if cfg.get("sub2api_auto_maintain"):
+ _start_sub2api_auto_maintain()
+
+
+@app.on_event("shutdown")
+async def _shutdown_background_tasks() -> None:
+ _service_shutdown_event.set()
+ _stop_auto_maintain()
+ _stop_sub2api_auto_maintain()
+
+
+# 挂载静态文件
+app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+
+# ==========================================
+# 入口(兼容直接运行)
+# ==========================================
+
+if __name__ == "__main__":
+ from .__main__ import main
+ main()
diff --git a/openai_pool_orchestrator/static/app.js b/openai_pool_orchestrator/static/app.js
new file mode 100755
index 0000000..1e3b6f0
--- /dev/null
+++ b/openai_pool_orchestrator/static/app.js
@@ -0,0 +1,2780 @@
+/**
+ * OpenAI Pool Orchestrator — v5.2.1
+ */
+
+// ==========================================
+// 状态
+// ==========================================
+const state = {
+ task: {
+ status: 'idle',
+ run_id: null,
+ revision: -1,
+ server_time: null,
+ },
+ runtime: {
+ run_id: null,
+ revision: -1,
+ focus_worker_id: null,
+ workers: [],
+ completion_semantics: 'registration_only',
+ },
+ stats: {
+ success: 0,
+ fail: 0,
+ total: 0,
+ },
+ ui: {
+ autoScroll: true,
+ logCount: 0,
+ focusWorkerId: null,
+ focusLocked: false,
+ eventSource: null,
+ tokens: [],
+ tokenFilter: {
+ status: 'all',
+ keyword: '',
+ },
+ sub2apiAccounts: [],
+ sub2apiAccountFilter: {
+ status: 'all',
+ keyword: '',
+ },
+ sub2apiAccountPager: {
+ page: 1,
+ pageSize: 20,
+ total: 0,
+ filteredTotal: 0,
+ totalPages: 1,
+ },
+ selectedSub2ApiAccountIds: new Set(),
+ sub2apiAccountsLoading: false,
+ sub2apiAccountActionBusy: false,
+ countdownTimer: null,
+ _loadTokensTimer: null,
+ latestRevisionByRun: {},
+ snapshotRequested: false,
+ dataPanelTab: 'dataPanelSub2Api',
+ },
+};
+
+// ==========================================
+// DOM 引用
+// ==========================================
+const $ = id => document.getElementById(id);
+const DOM = {};
+
+const STEP_DISPLAY_LABELS = {
+ check_proxy: '网络检查',
+ create_email: '创建邮箱',
+ oauth_init: 'OAuth 初始化',
+ sentinel: 'Sentinel Token',
+ signup: '提交注册',
+ send_otp: '发送验证码',
+ wait_otp: '等待验证码',
+ verify_otp: '验证 OTP',
+ create_account: '创建账户',
+ workspace: '选择 Workspace',
+ get_token: '获取 Token',
+ start: '开始新一轮',
+ saved: '保存 Token',
+ retry: '等待重试',
+ runtime: '运行异常',
+ wait: '等待下一轮',
+ stopped: '已停止',
+ dedupe: '重复检测',
+ sync: '同步 Sub2Api',
+ cpa_upload: '上传 CPA',
+ mode: '上传策略',
+ auto_stop: '自动停止',
+ stopping: '停止中',
+};
+
+const STATUS_LABEL_MAP = {
+ idle: '空闲',
+ starting: '启动中',
+ preparing: '准备中',
+ running: '运行中',
+ registering: '注册中',
+ postprocessing: '后处理中',
+ waiting: '等待中',
+ stopping: '停止中',
+ stopped: '已停止',
+ finished: '已完成',
+ failed: '失败',
+ error: '异常',
+};
+
+const PHASE_LABEL_MAP = {
+ preparing: '准备阶段',
+ registration: '注册阶段',
+ postprocess: '后处理阶段',
+ finished: '结束阶段',
+ idle: '等待任务',
+};
+
+const COMPLETION_SEMANTICS_MAP = {
+ registration_only: '注册完成即结束',
+ requires_postprocess: '注册完成后仍需后处理',
+};
+
+const WORKER_STATUS_PRIORITY = {
+ registering: 6,
+ postprocessing: 5,
+ preparing: 4,
+ running: 4,
+ waiting: 3,
+ error: 3,
+ stopping: 2,
+ stopped: 1,
+ idle: 0,
+};
+
+const SUB2API_ABNORMAL_STATUSES = new Set(['error', 'disabled']);
+
+function clearSub2ApiAccountKeywordInput() {
+ if (!DOM.sub2apiAccountKeyword) return;
+ if (state.ui.sub2apiAccountFilter.keyword) return;
+ DOM.sub2apiAccountKeyword.value = '';
+ requestAnimationFrame(() => {
+ if (!state.ui.sub2apiAccountFilter.keyword && DOM.sub2apiAccountKeyword) DOM.sub2apiAccountKeyword.value = '';
+ });
+ setTimeout(() => {
+ if (!state.ui.sub2apiAccountFilter.keyword && DOM.sub2apiAccountKeyword) DOM.sub2apiAccountKeyword.value = '';
+ }, 120);
+}
+
+// ==========================================
+// 初始化
+// ==========================================
+document.addEventListener('DOMContentLoaded', () => {
+ Object.assign(DOM, {
+ statusBadge: $('statusBadge'),
+ statusText: $('statusText'),
+ statusDot: $('statusDot'),
+ proxyInput: $('proxyInput'),
+ checkProxyBtn: $('checkProxyBtn'),
+ proxyStatus: $('proxyStatus'),
+ btnStart: $('btnStart'),
+ btnStop: $('btnStop'),
+ statSuccess: $('statSuccess'),
+ statFail: $('statFail'),
+ statTotal: $('statTotal'),
+ logBody: $('logBody'),
+ logCount: $('logCount'),
+ clearLogBtn: $('clearLogBtn'),
+ progressFill: $('progressFill'),
+ taskOverview: $('taskOverview'),
+ workerList: $('workerList'),
+ workerDetail: $('workerDetail'),
+ unlockFocusBtn: $('unlockFocusBtn'),
+ segmentIndicator: $('segmentIndicator'),
+ autoScrollCheck: $('autoScrollCheck'),
+ multithreadCheck: $('multithreadCheck'),
+ threadCountInput: $('threadCountInput'),
+ sub2apiBaseUrl: $('sub2apiBaseUrl'),
+ sub2apiEmail: $('sub2apiEmail'),
+ sub2apiPassword: $('sub2apiPassword'),
+ autoSyncCheck: $('autoSyncCheck'),
+ uploadMode: $('uploadMode'),
+ uploadModeSaveBtn: $('uploadModeSaveBtn'),
+ uploadModeStatus: $('uploadModeStatus'),
+ saveSyncConfigBtn: $('saveSyncConfigBtn'),
+ syncStatus: $('syncStatus'),
+ headerSub2apiChip: $('headerSub2apiChip'),
+ headerSub2apiLabel: $('headerSub2apiLabel'),
+ headerSub2apiDelta: $('headerSub2apiDelta'),
+ headerSub2apiBar: $('headerSub2apiBar'),
+ headerCpaChip: $('headerCpaChip'),
+ headerCpaLabel: $('headerCpaLabel'),
+ headerCpaDelta: $('headerCpaDelta'),
+ headerCpaBar: $('headerCpaBar'),
+ headerLocalTokenChip: $('headerLocalTokenChip'),
+ headerLocalTokenLabel: $('headerLocalTokenLabel'),
+ headerLocalTokenDelta: $('headerLocalTokenDelta'),
+ headerLocalTokenBar: $('headerLocalTokenBar'),
+ themeToggleBtn: $('themeToggleBtn'),
+ cpaBaseUrl: $('cpaBaseUrl'),
+ cpaToken: $('cpaToken'),
+ cpaMinCandidates: $('cpaMinCandidates'),
+ cpaUsedPercent: $('cpaUsedPercent'),
+ cpaAutoMaintain: $('cpaAutoMaintain'),
+ cpaInterval: $('cpaInterval'),
+ cpaTestBtn: $('cpaTestBtn'),
+ cpaSaveBtn: $('cpaSaveBtn'),
+ cpaStatus: $('cpaStatus'),
+ mailStrategySelect: $('mailStrategySelect'),
+ mailTestBtn: $('mailTestBtn'),
+ mailSaveBtn: $('mailSaveBtn'),
+ mailStatus: $('mailStatus'),
+ poolTotal: $('poolTotal'),
+ poolCandidates: $('poolCandidates'),
+ poolError: $('poolError'),
+ poolThreshold: $('poolThreshold'),
+ poolPercent: $('poolPercent'),
+ poolRefreshBtn: $('poolRefreshBtn'),
+ poolMaintainBtn: $('poolMaintainBtn'),
+ poolMaintainStatus: $('poolMaintainStatus'),
+ dataPanelSub2Api: $('dataPanelSub2Api'),
+ dataPanelCpa: $('dataPanelCpa'),
+ dataPanelLocalTokens: $('dataPanelLocalTokens'),
+ poolTokenList: $('poolTokenList'),
+ poolCopyRtBtn: $('poolCopyRtBtn'),
+ poolExportBtn: $('poolExportBtn'),
+ poolPwSyncBtn: $('poolPwSyncBtn'),
+ tokenFilterStatus: $('tokenFilterStatus'),
+ tokenFilterKeyword: $('tokenFilterKeyword'),
+ tokenFilterApplyBtn: $('tokenFilterApplyBtn'),
+ tokenFilterResetBtn: $('tokenFilterResetBtn'),
+ sub2apiPoolTotal: $('sub2apiPoolTotal'),
+ sub2apiPoolNormal: $('sub2apiPoolNormal'),
+ sub2apiPoolError: $('sub2apiPoolError'),
+ sub2apiPoolThreshold: $('sub2apiPoolThreshold'),
+ sub2apiPoolPercent: $('sub2apiPoolPercent'),
+ sub2apiPoolRefreshBtn: $('sub2apiPoolRefreshBtn'),
+ sub2apiPoolMaintainBtn: $('sub2apiPoolMaintainBtn'),
+ sub2apiPoolMaintainStatus: $('sub2apiPoolMaintainStatus'),
+ sub2apiAccountStatusFilter: $('sub2apiAccountStatusFilter'),
+ sub2apiAccountKeyword: $('sub2apiAccountKeyword'),
+ sub2apiAccountApplyBtn: $('sub2apiAccountApplyBtn'),
+ sub2apiAccountResetBtn: $('sub2apiAccountResetBtn'),
+ sub2apiAccountSelectAll: $('sub2apiAccountSelectAll'),
+ sub2apiAccountSelection: $('sub2apiAccountSelection'),
+ sub2apiAccountProbeBtn: $('sub2apiAccountProbeBtn'),
+ sub2apiAccountExceptionBtn: $('sub2apiAccountExceptionBtn'),
+ sub2apiDuplicateScanBtn: $('sub2apiDuplicateScanBtn'),
+ sub2apiDuplicateCleanBtn: $('sub2apiDuplicateCleanBtn'),
+ sub2apiAccountDeleteBtn: $('sub2apiAccountDeleteBtn'),
+ sub2apiAccountList: $('sub2apiAccountList'),
+ sub2apiAccountActionStatus: $('sub2apiAccountActionStatus'),
+ sub2apiAccountPrevBtn: $('sub2apiAccountPrevBtn'),
+ sub2apiAccountNextBtn: $('sub2apiAccountNextBtn'),
+ sub2apiAccountPageInfo: $('sub2apiAccountPageInfo'),
+ sub2apiAccountPageSize: $('sub2apiAccountPageSize'),
+ sub2apiMinCandidates: $('sub2apiMinCandidates'),
+ sub2apiInterval: $('sub2apiInterval'),
+ sub2apiAutoMaintain: $('sub2apiAutoMaintain'),
+ sub2apiTestPoolBtn: $('sub2apiTestPoolBtn'),
+ sub2apiMaintainRefreshAbnormal: $('sub2apiMaintainRefreshAbnormal'),
+ sub2apiMaintainDeleteAbnormal: $('sub2apiMaintainDeleteAbnormal'),
+ sub2apiMaintainDedupe: $('sub2apiMaintainDedupe'),
+ proxyPoolEnabled: $('proxyPoolEnabled'),
+ proxyPoolApiUrl: $('proxyPoolApiUrl'),
+ proxyPoolAuthMode: $('proxyPoolAuthMode'),
+ proxyPoolApiKey: $('proxyPoolApiKey'),
+ proxyPoolCount: $('proxyPoolCount'),
+ proxyPoolCountry: $('proxyPoolCountry'),
+ proxyPoolTestBtn: $('proxyPoolTestBtn'),
+ proxyPoolSaveBtn: $('proxyPoolSaveBtn'),
+ proxyPoolStatus: $('proxyPoolStatus'),
+ saveProxyBtn: $('saveProxyBtn'),
+ autoRegisterCheck: $('autoRegisterCheck'),
+ });
+
+ clearSub2ApiAccountKeywordInput();
+
+ renderRuntimePanels();
+ connectSSE();
+ loadTokens();
+ requestStatusSnapshot();
+ loadSyncConfig();
+ loadProxyPoolConfig();
+ loadPoolConfig();
+ loadMailConfig();
+ initMailCheckboxes();
+ pollPoolStatus();
+ pollSub2ApiPoolStatus();
+ loadSub2ApiAccounts();
+ initThemeSwitch();
+ initCollapsibles();
+ initDataPanelTabs();
+
+ DOM.checkProxyBtn.addEventListener('click', checkProxy);
+ if (DOM.saveProxyBtn) DOM.saveProxyBtn.addEventListener('click', saveProxy);
+ DOM.btnStart.addEventListener('click', startTask);
+ DOM.btnStop.addEventListener('click', stopTask);
+ DOM.clearLogBtn.addEventListener('click', clearLog);
+ if (DOM.unlockFocusBtn) DOM.unlockFocusBtn.addEventListener('click', unlockFocusWorker);
+
+ DOM.saveSyncConfigBtn.addEventListener('click', saveSyncConfig);
+ if (DOM.uploadModeSaveBtn) DOM.uploadModeSaveBtn.addEventListener('click', saveUploadMode);
+ DOM.cpaTestBtn.addEventListener('click', testCpaConnection);
+ DOM.cpaSaveBtn.addEventListener('click', savePoolConfig);
+ DOM.mailTestBtn.addEventListener('click', testMailConnection);
+ DOM.mailSaveBtn.addEventListener('click', saveMailConfig);
+
+ DOM.poolRefreshBtn.addEventListener('click', pollPoolStatus);
+ DOM.poolMaintainBtn.addEventListener('click', triggerMaintenance);
+ if (DOM.poolCopyRtBtn) DOM.poolCopyRtBtn.addEventListener('click', copyAllRt);
+ if (DOM.poolExportBtn) DOM.poolExportBtn.addEventListener('click', exportLocalTokens);
+ if (DOM.poolPwSyncBtn) DOM.poolPwSyncBtn.addEventListener('click', batchSync);
+ if (DOM.tokenFilterApplyBtn) DOM.tokenFilterApplyBtn.addEventListener('click', applyTokenFilter);
+ if (DOM.tokenFilterResetBtn) DOM.tokenFilterResetBtn.addEventListener('click', resetTokenFilter);
+ if (DOM.tokenFilterKeyword) {
+ DOM.tokenFilterKeyword.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') applyTokenFilter();
+ });
+ }
+ if (DOM.sub2apiPoolRefreshBtn) {
+ DOM.sub2apiPoolRefreshBtn.addEventListener('click', () => {
+ pollSub2ApiPoolStatus();
+ loadSub2ApiAccounts();
+ });
+ }
+ if (DOM.sub2apiPoolMaintainBtn) DOM.sub2apiPoolMaintainBtn.addEventListener('click', triggerSub2ApiMaintenance);
+ if (DOM.sub2apiTestPoolBtn) DOM.sub2apiTestPoolBtn.addEventListener('click', testSub2ApiPoolConnection);
+ if (DOM.sub2apiAccountApplyBtn) DOM.sub2apiAccountApplyBtn.addEventListener('click', applySub2ApiAccountFilter);
+ if (DOM.sub2apiAccountResetBtn) DOM.sub2apiAccountResetBtn.addEventListener('click', resetSub2ApiAccountFilter);
+ if (DOM.sub2apiAccountKeyword) {
+ DOM.sub2apiAccountKeyword.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') applySub2ApiAccountFilter();
+ });
+ }
+
+ window.addEventListener('pageshow', () => {
+ clearSub2ApiAccountKeywordInput();
+ });
+ if (DOM.sub2apiAccountPrevBtn) DOM.sub2apiAccountPrevBtn.addEventListener('click', () => changeSub2ApiAccountPage(-1));
+ if (DOM.sub2apiAccountNextBtn) DOM.sub2apiAccountNextBtn.addEventListener('click', () => changeSub2ApiAccountPage(1));
+ if (DOM.sub2apiAccountPageSize) {
+ DOM.sub2apiAccountPageSize.addEventListener('change', () => changeSub2ApiAccountPageSize());
+ }
+ if (DOM.sub2apiAccountSelectAll) DOM.sub2apiAccountSelectAll.addEventListener('change', toggleSelectAllSub2ApiAccounts);
+ if (DOM.sub2apiAccountProbeBtn) DOM.sub2apiAccountProbeBtn.addEventListener('click', triggerSelectedSub2ApiProbe);
+ if (DOM.sub2apiAccountExceptionBtn) DOM.sub2apiAccountExceptionBtn.addEventListener('click', triggerSub2ApiExceptionHandling);
+ if (DOM.sub2apiDuplicateScanBtn) DOM.sub2apiDuplicateScanBtn.addEventListener('click', previewSub2ApiDuplicates);
+ if (DOM.sub2apiDuplicateCleanBtn) DOM.sub2apiDuplicateCleanBtn.addEventListener('click', cleanupSub2ApiDuplicates);
+ if (DOM.sub2apiAccountDeleteBtn) DOM.sub2apiAccountDeleteBtn.addEventListener('click', triggerSelectedSub2ApiDelete);
+ if (DOM.proxyPoolTestBtn) DOM.proxyPoolTestBtn.addEventListener('click', testProxyPoolFetch);
+ if (DOM.proxyPoolSaveBtn) DOM.proxyPoolSaveBtn.addEventListener('click', saveProxyPoolConfig);
+
+ if (DOM.poolTokenList) {
+ DOM.poolTokenList.addEventListener('click', async (e) => {
+ const copyBtn = e.target.closest('.token-copy-btn');
+ if (copyBtn) {
+ try {
+ const payload = decodeURIComponent(copyBtn.dataset.payload || '');
+ await copyToken(payload);
+ } catch { showToast('复制失败', 'error'); }
+ return;
+ }
+ const deleteBtn = e.target.closest('.token-delete-btn');
+ if (deleteBtn) {
+ const filename = decodeURIComponent(deleteBtn.dataset.filename || '');
+ if (filename) deleteToken(filename);
+ }
+ });
+ }
+
+ if (DOM.sub2apiAccountList) {
+ DOM.sub2apiAccountList.addEventListener('click', async (e) => {
+ const probeBtn = e.target.closest('.sub2api-account-probe-btn');
+ if (probeBtn) {
+ const accountId = parseInt(probeBtn.dataset.accountId, 10);
+ if (Number.isInteger(accountId) && accountId > 0) {
+ await runSub2ApiAccountProbe([accountId], `账号 ${accountId}`);
+ }
+ return;
+ }
+ const deleteBtn = e.target.closest('.sub2api-account-delete-btn');
+ if (deleteBtn) {
+ const accountId = parseInt(deleteBtn.dataset.accountId, 10);
+ const email = decodeURIComponent(deleteBtn.dataset.email || '');
+ if (Number.isInteger(accountId) && accountId > 0) {
+ await runSub2ApiAccountDelete([accountId], email || `账号 ${accountId}`);
+ }
+ }
+ });
+ DOM.sub2apiAccountList.addEventListener('change', (e) => {
+ const checkbox = e.target.closest('.sub2api-account-check');
+ if (!checkbox) return;
+ const accountId = parseInt(checkbox.dataset.accountId, 10);
+ if (!Number.isInteger(accountId) || accountId <= 0) return;
+ if (checkbox.checked) state.ui.selectedSub2ApiAccountIds.add(accountId);
+ else state.ui.selectedSub2ApiAccountIds.delete(accountId);
+ const row = checkbox.closest('.sub2api-account-item');
+ if (row) row.classList.toggle('selected', checkbox.checked);
+ refreshSub2ApiSelectionState();
+ });
+ }
+
+ DOM.logBody.addEventListener('scroll', () => {
+ const el = DOM.logBody;
+ const isAtBottom = (el.scrollTop + el.clientHeight >= el.scrollHeight - 20);
+ state.ui.autoScroll = isAtBottom;
+ if (DOM.autoScrollCheck) DOM.autoScrollCheck.checked = isAtBottom;
+ });
+
+ if (DOM.autoScrollCheck) {
+ DOM.autoScrollCheck.checked = state.ui.autoScroll;
+ DOM.autoScrollCheck.addEventListener('change', () => {
+ state.ui.autoScroll = DOM.autoScrollCheck.checked;
+ if (state.ui.autoScroll) DOM.logBody.scrollTop = DOM.logBody.scrollHeight;
+ });
+ }
+
+ setInterval(requestStatusSnapshot, 5000);
+ setInterval(loadTokens, 60000);
+ setInterval(pollPoolStatus, 30000);
+ setInterval(pollSub2ApiPoolStatus, 30000);
+ setInterval(() => loadSub2ApiAccounts({ silent: true }), 60000);
+
+ initTabs();
+});
+
+// ==========================================
+// Tab 导航切换 — iOS Segmented Control
+// ==========================================
+function initTabs() {
+ const tabBtns = document.querySelectorAll('.tab-btn');
+ if (!tabBtns.length) return;
+
+ tabBtns.forEach((btn, index) => {
+ btn.addEventListener('click', () => {
+ switchMainTab(btn.dataset.tab || 'tabDashboard');
+ });
+ });
+
+ const activeTab = Array.from(tabBtns).find(btn => btn.classList.contains('active'))?.dataset.tab || 'tabDashboard';
+ switchMainTab(activeTab);
+}
+
+function switchMainTab(tabId) {
+ const nextTab = tabId === 'tabConfig' ? 'tabConfig' : 'tabDashboard';
+ const tabBtns = document.querySelectorAll('.tab-btn');
+ const tabPanels = document.querySelectorAll('.tab-panel');
+
+ tabBtns.forEach((btn, index) => {
+ const active = btn.dataset.tab === nextTab;
+ btn.classList.toggle('active', active);
+ btn.setAttribute('aria-selected', active ? 'true' : 'false');
+ if (active && DOM.segmentIndicator) {
+ DOM.segmentIndicator.setAttribute('data-active', String(index));
+ }
+ });
+
+ tabPanels.forEach((panel) => {
+ panel.classList.toggle('active', panel.id === nextTab);
+ });
+}
+
+function initDataPanelTabs() {
+ const defaultTab = 'dataPanelSub2Api';
+ const tabButtons = [DOM.headerSub2apiChip, DOM.headerCpaChip, DOM.headerLocalTokenChip].filter(Boolean);
+ if (!tabButtons.length) return;
+
+ tabButtons.forEach((btn, index) => {
+ btn.addEventListener('click', () => {
+ switchDataPanelTab(btn.dataset.panelTab || defaultTab);
+ });
+
+ btn.addEventListener('keydown', (event) => {
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
+ event.preventDefault();
+
+ let nextIndex = index;
+ if (event.key === 'ArrowRight') nextIndex = (index + 1) % tabButtons.length;
+ if (event.key === 'ArrowLeft') nextIndex = (index - 1 + tabButtons.length) % tabButtons.length;
+ if (event.key === 'Home') nextIndex = 0;
+ if (event.key === 'End') nextIndex = tabButtons.length - 1;
+
+ const targetBtn = tabButtons[nextIndex];
+ if (!targetBtn) return;
+ targetBtn.focus();
+ switchDataPanelTab(targetBtn.dataset.panelTab || defaultTab);
+ });
+ });
+
+ if (DOM.headerSub2apiChip) DOM.headerSub2apiChip.dataset.panelTab = 'dataPanelSub2Api';
+ if (DOM.headerCpaChip) DOM.headerCpaChip.dataset.panelTab = 'dataPanelCpa';
+ if (DOM.headerLocalTokenChip) DOM.headerLocalTokenChip.dataset.panelTab = 'dataPanelLocalTokens';
+
+ switchDataPanelTab(state.ui.dataPanelTab || defaultTab);
+}
+
+function switchDataPanelTab(tabId) {
+ const nextTab = ['dataPanelSub2Api', 'dataPanelCpa', 'dataPanelLocalTokens'].includes(tabId) ? tabId : 'dataPanelSub2Api';
+ state.ui.dataPanelTab = nextTab;
+
+ const panelMap = {
+ dataPanelSub2Api: DOM.dataPanelSub2Api,
+ dataPanelCpa: DOM.dataPanelCpa,
+ dataPanelLocalTokens: DOM.dataPanelLocalTokens,
+ };
+ const buttonMap = {
+ dataPanelSub2Api: DOM.headerSub2apiChip,
+ dataPanelCpa: DOM.headerCpaChip,
+ dataPanelLocalTokens: DOM.headerLocalTokenChip,
+ };
+
+ Object.entries(panelMap).forEach(([id, panel]) => {
+ if (!panel) return;
+ panel.classList.toggle('active', id === nextTab);
+ });
+ Object.entries(buttonMap).forEach(([id, btn]) => {
+ if (!btn) return;
+ const active = id === nextTab;
+ btn.classList.toggle('active-view', active);
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
+ btn.tabIndex = active ? 0 : -1;
+ });
+
+ const dashboardActive = document.getElementById('tabDashboard')?.classList.contains('active');
+ if (!dashboardActive) {
+ switchMainTab('tabDashboard');
+ }
+}
+
+// ==========================================
+// 折叠面板
+// ==========================================
+function initCollapsibles() {
+ document.querySelectorAll('.collapsible-trigger').forEach(trigger => {
+ trigger.addEventListener('click', () => {
+ const section = trigger.closest('.collapsible');
+ if (!section) return;
+ const body = section.querySelector('.collapsible-body');
+ if (!body) return;
+ const icon = trigger.querySelector('.collapse-icon');
+ const isOpen = section.classList.contains('open');
+ if (isOpen) {
+ section.classList.remove('open');
+ body.style.display = 'none';
+ if (icon) icon.classList.remove('open');
+ } else {
+ section.classList.add('open');
+ body.style.display = 'block';
+ if (icon) icon.classList.add('open');
+ }
+ });
+ });
+}
+
+// ==========================================
+// SSE / 快照同步
+// ==========================================
+function connectSSE() {
+ if (state.ui.eventSource) state.ui.eventSource.close();
+ const es = new EventSource('/api/logs');
+ state.ui.eventSource = es;
+
+ const handleEvent = (sourceType, raw) => {
+ try {
+ const payload = raw?.data ? JSON.parse(raw.data) : {};
+ const event = payload && typeof payload === 'object' ? { ...payload } : {};
+ if (!event.type && sourceType && sourceType !== 'message') event.type = sourceType;
+ if (!event.type && event.event) event.type = event.event;
+
+ if (event.type) {
+ applySseEvent(event);
+ return;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(event, 'task')
+ || Object.prototype.hasOwnProperty.call(event, 'runtime')
+ || Object.prototype.hasOwnProperty.call(event, 'stats')) {
+ applyStatusSnapshot(event);
+ }
+ } catch { }
+ };
+
+ ['connected', 'snapshot', 'task.updated', 'worker.updated', 'worker.step.updated', 'stats.updated', 'log.appended', 'task.finished']
+ .forEach((eventName) => {
+ es.addEventListener(eventName, (e) => handleEvent(eventName, e));
+ });
+
+ es.onmessage = (e) => handleEvent('message', e);
+ es.onerror = () => setTimeout(connectSSE, 3000);
+}
+
+// ==========================================
+// 日志渲染
+// ==========================================
+const LEVEL_ICON = { info: '›', success: '✓', error: '✗', warn: '⚠', connected: '⟳' };
+
+function appendLog(event) {
+ const { ts, level, message, step } = event;
+ state.ui.logCount++;
+ const entry = document.createElement('div');
+ entry.className = 'log-entry';
+ entry.innerHTML = `
+ ${escapeHtml(ts || '')}
+ ${LEVEL_ICON[level] || '·'}
+ ${escapeHtml(message || '')}
+ ${step ? `${escapeHtml(getStepDisplayLabel(step))}` : ''}
+ `;
+ DOM.logBody.appendChild(entry);
+ DOM.logCount.textContent = state.ui.logCount;
+ if (state.ui.autoScroll) DOM.logBody.scrollTop = DOM.logBody.scrollHeight;
+ if (DOM.logBody.children.length > 2000) {
+ DOM.logBody.firstElementChild.remove();
+ }
+}
+
+function clearLog() {
+ DOM.logBody.innerHTML = '';
+ state.ui.logCount = 0;
+ DOM.logCount.textContent = '0';
+}
+
+function normalizeRevision(value, fallback = -1) {
+ const num = Number(value);
+ return Number.isFinite(num) ? num : fallback;
+}
+
+function normalizeRunId(runId) {
+ if (runId === null || runId === undefined || runId === '') return null;
+ const value = String(runId).trim();
+ return value || null;
+}
+
+function normalizeWorkerId(workerId) {
+ if (workerId === null || workerId === undefined || workerId === '') return null;
+ const value = String(workerId).trim();
+ return value || null;
+}
+
+function normalizeTaskSnapshot(task, serverTime = null) {
+ const source = task && typeof task === 'object' ? task : {};
+ return {
+ ...state.task,
+ ...source,
+ status: source.status || 'idle',
+ run_id: normalizeRunId(source.run_id) || null,
+ revision: normalizeRevision(source.revision, state.task.revision),
+ server_time: serverTime || source.server_time || state.task.server_time || null,
+ };
+}
+
+function normalizeStatsSnapshot(stats) {
+ const source = stats && typeof stats === 'object' ? stats : {};
+ const success = Number(source.success || 0);
+ const fail = Number(source.fail || 0);
+ const total = Number.isFinite(Number(source.total)) ? Number(source.total) : (success + fail);
+ return {
+ ...state.stats,
+ ...source,
+ success,
+ fail,
+ total,
+ };
+}
+
+function normalizeWorkerStep(step, fallbackIndex = 0) {
+ if (!step) return null;
+
+ const id = String(step.id || step.step_id || step.step || '').trim();
+ if (!id) return null;
+ const rawStatus = String(step.status || step.state || 'pending').toLowerCase();
+ let status = rawStatus;
+ if (['done', 'completed', 'ok'].includes(rawStatus)) status = 'done';
+ else if (['error', 'failed', 'fail'].includes(rawStatus)) status = 'error';
+ else if (['active', 'running', 'in_progress'].includes(rawStatus)) status = 'active';
+ else if (['skipped'].includes(rawStatus)) status = 'skipped';
+ else status = 'pending';
+
+ return {
+ ...step,
+ id,
+ step_id: step.step_id || id,
+ label: step.label || id,
+ status,
+ message: step.message || '',
+ index: Number.isFinite(Number(step.index)) ? Number(step.index) : fallbackIndex,
+ started_at: step.started_at || '',
+ finished_at: step.finished_at || '',
+ updated_at: step.updated_at || step.finished_at || step.started_at || '',
+ };
+}
+
+const MAX_WORKER_STEP_ITEMS = 16;
+
+function normalizeWorkerSteps(steps) {
+ const normalized = Array.isArray(steps)
+ ? steps
+ .map((step, index) => normalizeWorkerStep(step, index))
+ .filter(Boolean)
+ : (steps && typeof steps === 'object')
+ ? Object.entries(steps)
+ .map(([id, status], index) => normalizeWorkerStep({ id, status, index }, index))
+ .filter(Boolean)
+ : [];
+
+ if (!normalized.length) return [];
+
+ const deduped = new Map();
+ normalized.forEach((step, index) => {
+ const key = String(step.step_id || step.id || '').trim();
+ if (!key) return;
+
+ const normalizedIndex = Number.isFinite(Number(step.index)) ? Number(step.index) : index;
+ const nextStep = { ...step, step_id: key, id: key, index: normalizedIndex };
+ const previous = deduped.get(key);
+ if (!previous) {
+ deduped.set(key, nextStep);
+ return;
+ }
+
+ const previousUpdated = String(previous.updated_at || previous.finished_at || previous.started_at || '');
+ const nextUpdated = String(nextStep.updated_at || nextStep.finished_at || nextStep.started_at || '');
+ if (nextUpdated >= previousUpdated) {
+ deduped.set(key, { ...previous, ...nextStep, index: Math.min(previous.index, normalizedIndex) });
+ }
+ });
+
+ return [...deduped.values()]
+ .sort((a, b) => {
+ const ai = Number.isFinite(a.index) ? a.index : Number.MAX_SAFE_INTEGER;
+ const bi = Number.isFinite(b.index) ? b.index : Number.MAX_SAFE_INTEGER;
+ if (ai !== bi) return ai - bi;
+ return String(a.updated_at || '').localeCompare(String(b.updated_at || ''));
+ })
+ .slice(-MAX_WORKER_STEP_ITEMS);
+}
+
+function normalizeWorker(worker, fallbackId = null) {
+ const source = worker && typeof worker === 'object' ? worker : {};
+ const workerId = normalizeWorkerId(source.worker_id ?? fallbackId);
+ if (!workerId) return null;
+
+ return {
+ ...source,
+ worker_id: workerId,
+ worker_label: source.worker_label || `W${workerId}`,
+ status: source.status || 'idle',
+ phase: source.phase || 'idle',
+ revision: normalizeRevision(source.revision ?? source.runtime_revision, -1),
+ current_step: source.current_step || '',
+ message: source.message || '',
+ email: source.email || source.account_email || '',
+ mail_provider: source.mail_provider || '',
+ updated_at: source.updated_at || source.ts || '',
+ steps: normalizeWorkerSteps(source.steps),
+ };
+}
+
+function normalizeRuntimeSnapshot(runtime, taskRunId = null) {
+ const source = runtime && typeof runtime === 'object' ? runtime : {};
+ const workers = Array.isArray(source.workers)
+ ? source.workers.map(worker => normalizeWorker(worker)).filter(Boolean)
+ : Object.entries(source.workers || {}).map(([workerId, worker]) => normalizeWorker(worker, workerId)).filter(Boolean);
+
+ return {
+ ...state.runtime,
+ ...source,
+ run_id: normalizeRunId(source.run_id) || taskRunId || null,
+ revision: normalizeRevision(source.revision, state.runtime.revision),
+ focus_worker_id: normalizeWorkerId(source.focus_worker_id),
+ completion_semantics: source.completion_semantics || state.runtime.completion_semantics || 'registration_only',
+ workers,
+ };
+}
+
+function getKnownRevision(runId) {
+ const key = normalizeRunId(runId);
+ if (!key) return -1;
+ return normalizeRevision(state.ui.latestRevisionByRun[key], -1);
+}
+
+function rememberRevision(runId, revision) {
+ const key = normalizeRunId(runId);
+ if (!key || !Number.isFinite(revision)) return;
+ state.ui.latestRevisionByRun[key] = Math.max(getKnownRevision(key), revision);
+}
+
+function shouldIgnoreEvent(runId, revision) {
+ const key = normalizeRunId(runId) || normalizeRunId(state.task.run_id) || normalizeRunId(state.runtime.run_id);
+ if (!key || !Number.isFinite(revision)) return false;
+ const known = getKnownRevision(key);
+ if (known >= 0 && revision < known) return true;
+ if (known >= 0 && revision > known + 1) requestStatusSnapshot();
+ rememberRevision(key, revision);
+ return false;
+}
+
+function requestStatusSnapshot() {
+ if (state.ui.snapshotRequested) return;
+ state.ui.snapshotRequested = true;
+ fetch('/api/status')
+ .then(res => res.json())
+ .then(payload => applyStatusSnapshot(payload, { force: true }))
+ .catch(() => {})
+ .finally(() => {
+ state.ui.snapshotRequested = false;
+ });
+}
+
+function applyStatusSnapshot(payload, { force = false } = {}) {
+ if (!payload || typeof payload !== 'object') return;
+
+ const nextTask = normalizeTaskSnapshot(payload.task, payload.server_time || null);
+ const nextRuntime = normalizeRuntimeSnapshot(payload.runtime, nextTask.run_id);
+ const snapshotRevision = Math.max(nextTask.revision, nextRuntime.revision);
+ const snapshotRunId = normalizeRunId(nextTask.run_id) || normalizeRunId(nextRuntime.run_id);
+
+ if (!force && shouldIgnoreEvent(snapshotRunId, snapshotRevision)) return;
+ rememberRevision(snapshotRunId, snapshotRevision);
+
+ state.task = nextTask;
+ state.runtime = nextRuntime;
+ state.stats = normalizeStatsSnapshot(payload.stats);
+
+ ensureFocusWorker();
+ syncTaskChrome();
+ renderRuntimePanels();
+}
+
+function applySseEvent(event) {
+ if (!event || typeof event !== 'object') return;
+ const type = String(event.type || event.event || '').trim();
+ const runId = normalizeRunId(event.run_id || event.task?.run_id || event.runtime?.run_id || event.worker?.run_id);
+ const revision = normalizeRevision(event.revision ?? event.task?.revision ?? event.runtime?.revision ?? event.worker?.revision, NaN);
+
+ if (type && shouldIgnoreEvent(runId, revision)) return;
+
+ if (type === 'connected') {
+ appendLog({ ts: event.ts || '', level: 'connected', message: event.message || '实时事件已连接' });
+ if (event.snapshot) applyStatusSnapshot(event.snapshot, { force: true });
+ else requestStatusSnapshot();
+ return;
+ }
+
+ if (type === 'snapshot') {
+ applyStatusSnapshot(event.snapshot || event.payload || event, { force: true });
+ return;
+ }
+
+ if (type === 'log.appended') {
+ const logEvent = event.log && typeof event.log === 'object' ? event.log : event;
+ appendLog(logEvent);
+ if (logEvent.level === 'token_saved') {
+ debouncedLoadTokens();
+ showToast('新 Token 已保存: ' + (logEvent.message || ''), 'success');
+ }
+ if (logEvent.level === 'sync_ok') {
+ showToast('已自动同步: ' + (logEvent.message || ''), 'success');
+ }
+ if (logEvent.step === 'wait' && logEvent.message) {
+ const match = String(logEvent.message).match(/(\d+)\s*秒/);
+ if (match) startCountdown(parseInt(match[1], 10));
+ }
+ return;
+ }
+
+ if (type === 'task.updated' || type === 'task.finished') {
+ state.task = normalizeTaskSnapshot({ ...state.task, ...(event.task || event) }, event.server_time || state.task.server_time);
+ if (type === 'task.finished') requestStatusSnapshot();
+ syncTaskChrome();
+ renderRuntimePanels();
+ return;
+ }
+
+ if (type === 'stats.updated') {
+ state.stats = normalizeStatsSnapshot({ ...state.stats, ...(event.stats || event) });
+ syncTaskChrome();
+ renderRuntimePanels();
+ return;
+ }
+
+ if (type === 'worker.updated') {
+ mergeWorkerIntoRuntime(event.worker || event.runtime || event);
+ return;
+ }
+
+ if (type === 'worker.step.updated') {
+ mergeWorkerStepUpdate(event);
+ return;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(event, 'task')
+ || Object.prototype.hasOwnProperty.call(event, 'runtime')
+ || Object.prototype.hasOwnProperty.call(event, 'stats')) {
+ applyStatusSnapshot(event);
+ }
+}
+
+// ==========================================
+// 代理检测
+// ==========================================
+async function checkProxy() {
+ const proxy = DOM.proxyInput.value.trim();
+ if (!proxy) { showToast('请先填写代理地址', 'error'); return; }
+ DOM.proxyStatus.className = 'proxy-status';
+ DOM.proxyStatus.innerHTML = '检测中...';
+ DOM.checkProxyBtn.disabled = true;
+ try {
+ const res = await fetch('/api/check-proxy', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ proxy }),
+ });
+ const data = await res.json();
+ if (data.ok) {
+ DOM.proxyStatus.className = 'proxy-status ok';
+ DOM.proxyStatus.innerHTML = `可用 · 所在地: ${escapeHtml(data.loc || '')}`;
+ } else {
+ DOM.proxyStatus.className = 'proxy-status fail';
+ DOM.proxyStatus.innerHTML = `不可用 · ${escapeHtml(data.error || '')}`;
+ }
+ } catch {
+ DOM.proxyStatus.className = 'proxy-status fail';
+ DOM.proxyStatus.innerHTML = '检测请求失败';
+ } finally {
+ DOM.checkProxyBtn.disabled = false;
+ }
+}
+
+// ==========================================
+// 代理保存
+// ==========================================
+async function saveProxy() {
+ const proxy = DOM.proxyInput.value.trim();
+ const auto_register = DOM.autoRegisterCheck ? DOM.autoRegisterCheck.checked : false;
+ try {
+ const res = await fetch('/api/proxy/save', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ proxy, auto_register }),
+ });
+ if (res.ok) {
+ showToast('代理配置已保存', 'success');
+ } else {
+ showToast('保存失败', 'error');
+ }
+ } catch (e) {
+ showToast('保存请求失败: ' + e.message, 'error');
+ }
+}
+
+// ==========================================
+// 启动 / 停止任务
+// ==========================================
+function getRequestedWorkerCount() {
+ const multithread = DOM.multithreadCheck ? DOM.multithreadCheck.checked : false;
+ if (!multithread) return 1;
+ return Math.max(1, DOM.threadCountInput ? (parseInt(DOM.threadCountInput.value, 10) || 1) : 1);
+}
+
+async function startTask() {
+ const proxy = DOM.proxyInput.value.trim();
+ const worker_count = getRequestedWorkerCount();
+ try {
+ const res = await fetch('/api/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ proxy, worker_count }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ showToast(data.detail || '启动失败', 'error');
+ return;
+ }
+ applyStatusSnapshot(data, { force: true });
+ const workerMsg = worker_count > 1 ? ` (${worker_count} 线程)` : '';
+ showToast('注册任务已启动' + workerMsg, 'success');
+ } catch (e) {
+ showToast('启动请求失败: ' + e.message, 'error');
+ }
+}
+
+async function stopTask() {
+ try {
+ const res = await fetch('/api/stop', { method: 'POST' });
+ const data = await res.json();
+ if (!res.ok) {
+ showToast(data.detail || '停止失败', 'error');
+ return;
+ }
+ applyStatusSnapshot(data, { force: true });
+ showToast('正在停止任务...', 'info');
+ requestStatusSnapshot();
+ } catch (e) {
+ showToast('停止请求失败: ' + e.message, 'error');
+ }
+}
+
+// ==========================================
+// 状态更新 / 渲染
+// ==========================================
+function syncTaskChrome() {
+ const status = state.task.status || 'idle';
+ DOM.statusBadge.className = `status-badge ${status}`;
+ DOM.statusText.textContent = formatTaskStatusLabel(status);
+
+ const hasLiveRun = Boolean(state.task.run_id) && !state.task.finished_at;
+ const isActive = ['starting', 'running'].includes(status);
+ const isStopping = status === 'stopping';
+ const canStop = hasLiveRun && ['starting', 'running', 'failed'].includes(status);
+ DOM.btnStart.disabled = hasLiveRun || isStopping;
+ DOM.btnStop.disabled = !canStop;
+ DOM.progressFill.className = isActive
+ ? 'progress-fill running'
+ : (isStopping ? 'progress-fill stopping' : 'progress-fill');
+
+ if (DOM.statSuccess) DOM.statSuccess.textContent = state.stats.success;
+ if (DOM.statFail) DOM.statFail.textContent = state.stats.fail;
+ if (DOM.statTotal) DOM.statTotal.textContent = state.stats.total;
+
+ if (status === 'idle' && state.ui.countdownTimer) {
+ clearInterval(state.ui.countdownTimer);
+ state.ui.countdownTimer = null;
+ }
+}
+
+function formatTaskStatusLabel(status) {
+ return STATUS_LABEL_MAP[status] || (status ? String(status) : '等待开始');
+}
+
+function getStepDisplayLabel(stepId) {
+ return STEP_DISPLAY_LABELS[stepId] || (stepId ? String(stepId) : '等待开始');
+}
+
+function getWorkerStatusLabel(status) {
+ return STATUS_LABEL_MAP[status] || (status ? String(status) : '等待开始');
+}
+
+function getPhaseLabel(phase) {
+ return PHASE_LABEL_MAP[phase] || (phase ? String(phase) : '等待任务');
+}
+
+function getCompletionSemanticsLabel(value) {
+ return COMPLETION_SEMANTICS_MAP[value] || '注册完成即结束';
+}
+
+function getWorkerSortKey(worker) {
+ return [
+ WORKER_STATUS_PRIORITY[worker?.status] || 0,
+ worker?.updated_at || '',
+ Number(worker?.worker_id || 0),
+ ];
+}
+
+function compareWorkerRuntime(a, b) {
+ const [sa, ua, wa] = getWorkerSortKey(a);
+ const [sb, ub, wb] = getWorkerSortKey(b);
+ if (sa !== sb) return sb - sa;
+ if (ua !== ub) return ub.localeCompare(ua);
+ return wb - wa;
+}
+
+function sortWorkers(workers) {
+ return [...workers].sort(compareWorkerRuntime);
+}
+
+function ensureFocusWorker() {
+ const workers = sortWorkers(state.runtime.workers || []);
+ const lockedId = normalizeWorkerId(state.ui.focusWorkerId);
+ if (state.ui.focusLocked && lockedId && workers.some(worker => worker.worker_id === lockedId)) return;
+
+ const backendFocus = normalizeWorkerId(state.runtime.focus_worker_id);
+ if (backendFocus && workers.some(worker => worker.worker_id === backendFocus)) {
+ state.ui.focusWorkerId = backendFocus;
+ return;
+ }
+
+ state.ui.focusWorkerId = workers[0]?.worker_id || null;
+}
+
+function getFocusWorker() {
+ const focusId = normalizeWorkerId(state.ui.focusWorkerId);
+ if (!focusId) return null;
+ return (state.runtime.workers || []).find(worker => worker.worker_id === focusId) || null;
+}
+
+function selectFocusWorker(nextId, { lock = false } = {}) {
+ const normalizedId = normalizeWorkerId(nextId);
+ if (!normalizedId) return;
+ if (!(state.runtime.workers || []).some(worker => worker.worker_id === normalizedId)) return;
+ state.ui.focusWorkerId = normalizedId;
+ if (lock) state.ui.focusLocked = true;
+ renderRuntimePanels();
+}
+
+function unlockFocusWorker() {
+ state.ui.focusLocked = false;
+ ensureFocusWorker();
+ renderRuntimePanels();
+}
+
+function mergeWorkerIntoRuntime(workerPatch) {
+ const normalizedWorker = normalizeWorker(workerPatch);
+ if (!normalizedWorker) return;
+
+ const workers = [...(state.runtime.workers || [])];
+ const index = workers.findIndex(worker => worker.worker_id === normalizedWorker.worker_id);
+ if (index >= 0) {
+ const prevWorker = workers[index];
+ workers[index] = normalizeWorker({
+ ...prevWorker,
+ ...normalizedWorker,
+ steps: normalizedWorker.steps.length ? normalizedWorker.steps : prevWorker.steps,
+ }, normalizedWorker.worker_id);
+ } else {
+ workers.push(normalizedWorker);
+ }
+
+ state.runtime = {
+ ...state.runtime,
+ run_id: normalizeRunId(normalizedWorker.run_id) || state.runtime.run_id || state.task.run_id,
+ focus_worker_id: normalizeWorkerId(state.runtime.focus_worker_id) || normalizedWorker.worker_id,
+ workers: sortWorkers(workers),
+ };
+
+ ensureFocusWorker();
+ renderRuntimePanels();
+}
+
+function upsertWorkerStep(existingSteps, stepPatch) {
+ const steps = normalizeWorkerSteps(existingSteps);
+ const normalizedStep = normalizeWorkerStep(stepPatch, steps.length);
+ if (!normalizedStep) return steps;
+
+ const next = [...steps];
+ const index = next.findIndex(step => step.id === normalizedStep.id);
+ if (index >= 0) next[index] = { ...next[index], ...normalizedStep };
+ else next.push(normalizedStep);
+ return normalizeWorkerSteps(next);
+}
+
+function mergeWorkerStepUpdate(event) {
+ const workerSource = event.worker && typeof event.worker === 'object' ? event.worker : event;
+ const workerId = normalizeWorkerId(workerSource.worker_id || event.worker_id);
+ if (!workerId) {
+ requestStatusSnapshot();
+ return;
+ }
+
+ const workers = [...(state.runtime.workers || [])];
+ const index = workers.findIndex(worker => worker.worker_id === workerId);
+ const baseWorker = index >= 0 ? workers[index] : normalizeWorker({ worker_id: workerId, worker_label: `W${workerId}` }, workerId);
+ const nextWorker = normalizeWorker({
+ ...baseWorker,
+ ...workerSource,
+ worker_id: workerId,
+ steps: workerSource.steps || upsertWorkerStep(baseWorker?.steps || [], event.step || workerSource.step || workerSource),
+ }, workerId);
+
+ if (index >= 0) workers[index] = nextWorker;
+ else workers.push(nextWorker);
+
+ state.runtime = {
+ ...state.runtime,
+ workers: sortWorkers(workers),
+ focus_worker_id: normalizeWorkerId(event.focus_worker_id) || state.runtime.focus_worker_id || workerId,
+ };
+
+ ensureFocusWorker();
+ renderRuntimePanels();
+}
+
+function getWorkerPrimaryStep(worker) {
+ if (!worker) return null;
+ const steps = Array.isArray(worker.steps) ? worker.steps : [];
+ const activeStep = steps.find(step => step.status === 'active');
+ if (activeStep) return activeStep;
+ return steps[steps.length - 1] || null;
+}
+
+function renderTaskOverview(task, runtime, stats) {
+ if (!DOM.taskOverview) return;
+ const workers = Array.isArray(runtime?.workers) ? runtime.workers : [];
+ const activeWorkers = workers.filter(worker => !['idle', 'stopped'].includes(String(worker.status || 'idle'))).length;
+ const cards = [
+ { label: '任务状态', value: formatTaskStatusLabel(task?.status || 'idle'), hint: task?.status || 'idle', status: `task-status-${task?.status || 'idle'}` },
+ { label: '运行标识', value: task?.run_id || '--', hint: `revision ${normalizeRevision(task?.revision, 0)}`, status: 'task-status-meta' },
+ { label: 'Worker', value: `${activeWorkers}/${workers.length}`, hint: `focus ${runtime?.focus_worker_id || '--'}`, status: 'task-status-meta' },
+ { label: '成功 / 失败', value: `${stats?.success || 0} / ${stats?.fail || 0}`, hint: `total ${stats?.total || 0}`, status: 'task-status-meta' },
+ ];
+
+ if (!task?.run_id && (task?.status || 'idle') === 'idle' && workers.length === 0) {
+ DOM.taskOverview.innerHTML = '等待任务启动
';
+ return;
+ }
+
+ DOM.taskOverview.innerHTML = cards.map(card => `
+
+ ${escapeHtml(card.label)}
+ ${escapeHtml(card.value)}
+ ${escapeHtml(card.hint)}
+
+ `).join('');
+}
+
+function renderWorkerList(workers, focusWorkerId) {
+ if (!DOM.workerList) return;
+ const entries = sortWorkers(Array.isArray(workers) ? workers : []);
+ if (!entries.length) {
+ DOM.workerList.innerHTML = '暂无 Worker 运行
';
+ return;
+ }
+
+ DOM.workerList.innerHTML = entries.map((worker) => {
+ const workerId = worker.worker_id;
+ const focused = normalizeWorkerId(focusWorkerId) === workerId;
+ const primaryStep = getWorkerPrimaryStep(worker);
+ const stepLabel = primaryStep ? primaryStep.label : '等待开始';
+ const email = worker.email || '等待邮箱创建';
+ const updatedAt = worker.updated_at || '--';
+ const status = String(worker.status || 'idle');
+ return `
+
+ `;
+ }).join('');
+
+ DOM.workerList.querySelectorAll('[data-worker-id]').forEach((button) => {
+ button.addEventListener('click', () => selectFocusWorker(button.dataset.workerId, { lock: true }));
+ });
+}
+
+function renderWorkerDetail(focusWorker) {
+ if (!DOM.workerDetail) return;
+ if (!focusWorker) {
+ DOM.workerDetail.className = 'worker-detail-card empty';
+ DOM.workerDetail.innerHTML = '等待任务启动';
+ if (DOM.unlockFocusBtn) DOM.unlockFocusBtn.disabled = !state.ui.focusLocked;
+ return;
+ }
+
+ const status = String(focusWorker.status || 'idle');
+ const completionSemantics = getCompletionSemanticsLabel(state.runtime.completion_semantics || 'registration_only');
+ const metaItems = [
+ { label: 'Worker', value: focusWorker.worker_label || `W${focusWorker.worker_id}` },
+ { label: '状态', value: `${getWorkerStatusLabel(status)} · ${getPhaseLabel(focusWorker.phase || 'idle')}` },
+ { label: '邮箱', value: focusWorker.email || '等待邮箱创建' },
+ { label: '邮箱提供商', value: focusWorker.mail_provider || '--' },
+ { label: '当前步骤', value: getWorkerPrimaryStep(focusWorker)?.label || '等待开始' },
+ { label: '完成语义', value: completionSemantics },
+ { label: '更新时间', value: focusWorker.updated_at || '--' },
+ { label: '进度消息', value: focusWorker.message || '等待后端步骤更新', wide: true },
+ ];
+
+ const steps = Array.isArray(focusWorker.steps) ? focusWorker.steps : [];
+ const stepsHtml = steps.length
+ ? steps.map((step) => `
+
+
+ ${escapeHtml(step.label || step.step_id || step.id || '未命名步骤')}
+ ${escapeHtml(step.status || 'pending')}
+
+ ${step.message ? `
${escapeHtml(step.message)}
` : ''}
+ ${step.updated_at ? `
${escapeHtml(step.updated_at)}
` : ''}
+
+ `).join('')
+ : '暂无步骤轨道
';
+
+ DOM.workerDetail.className = `worker-detail-card worker-detail-${escapeHtml(status)}`;
+ DOM.workerDetail.innerHTML = `
+
+
+ `;
+
+ if (DOM.unlockFocusBtn) DOM.unlockFocusBtn.disabled = !state.ui.focusLocked;
+}
+
+function renderRuntimePanels() {
+ renderTaskOverview(state.task, state.runtime, state.stats);
+ renderWorkerList(state.runtime.workers, state.ui.focusWorkerId);
+ renderWorkerDetail(getFocusWorker());
+}
+
+function startCountdown(seconds) {
+ if (state.ui.countdownTimer) clearInterval(state.ui.countdownTimer);
+ let remaining = seconds;
+ const entries = DOM.logBody.querySelectorAll('.log-entry');
+ const countdownEntry = entries.length > 0 ? entries[entries.length - 1] : null;
+ const countdownMsgEl = countdownEntry ? countdownEntry.querySelector('.log-msg') : null;
+ state.ui.countdownTimer = setInterval(() => {
+ remaining--;
+ if (remaining <= 0) { clearInterval(state.ui.countdownTimer); state.ui.countdownTimer = null; return; }
+ if (countdownMsgEl) countdownMsgEl.textContent = `休息中... 剩余 ${remaining} 秒`;
+ }, 1000);
+}
+
+// ==========================================
+// Token 列表
+// ==========================================
+function debouncedLoadTokens() {
+ if (state.ui._loadTokensTimer) clearTimeout(state.ui._loadTokensTimer);
+ state.ui._loadTokensTimer = setTimeout(() => {
+ loadTokens();
+ state.ui._loadTokensTimer = null;
+ }, 1000);
+}
+
+async function loadTokens() {
+ try {
+ const res = await fetch('/api/tokens');
+ const data = await res.json();
+ state.ui.tokens = data.tokens || [];
+ renderTokenList();
+ } catch { }
+}
+
+function getFilteredTokens(tokens) {
+ const status = state.ui.tokenFilter.status || 'all';
+ const keyword = (state.ui.tokenFilter.keyword || '').trim().toLowerCase();
+
+ return (tokens || []).filter((t) => {
+ const platforms = getTokenUploadedPlatforms(t);
+ const uploaded = platforms.length > 0;
+ if (status === 'synced' && !uploaded) return false;
+ if (status === 'unsynced' && uploaded) return false;
+ if (status === 'cpa' && !platforms.includes('cpa')) return false;
+ if (status === 'sub2api' && !platforms.includes('sub2api')) return false;
+ if (status === 'both' && !(platforms.includes('cpa') && platforms.includes('sub2api'))) return false;
+
+ if (!keyword) return true;
+ const email = String(t.email || '').toLowerCase();
+ const fname = String(t.filename || '').toLowerCase();
+ return email.includes(keyword) || fname.includes(keyword);
+ });
+}
+
+function getTokenUploadedPlatforms(token) {
+ const platforms = new Set();
+ const fromTop = Array.isArray(token && token.uploaded_platforms) ? token.uploaded_platforms : [];
+ const content = (token && token.content) || {};
+ const fromContent = Array.isArray(content.uploaded_platforms) ? content.uploaded_platforms : [];
+ [...fromTop, ...fromContent].forEach((p) => {
+ const name = String(p || '').toLowerCase().trim();
+ if (name === 'cpa' || name === 'sub2api') platforms.add(name);
+ });
+ if (content.cpa_uploaded || content.cpa_synced) platforms.add('cpa');
+ if (content.sub2api_uploaded || content.sub2api_synced || content.synced) platforms.add('sub2api');
+ return ['cpa', 'sub2api'].filter((p) => platforms.has(p));
+}
+
+function renderTokenList() {
+ const allTokens = state.ui.tokens || [];
+ const filteredTokens = getFilteredTokens(allTokens);
+ updateHeaderLocalTokens(allTokens);
+
+ if (!DOM.poolTokenList) return;
+ if (filteredTokens.length === 0) {
+ const msg = allTokens.length === 0 ? '暂无 Token' : '暂无符合筛选条件的 Token';
+ DOM.poolTokenList.innerHTML = ``;
+ return;
+ }
+ DOM.poolTokenList.innerHTML = filteredTokens.map(t => renderTokenItem(t)).join('');
+}
+
+function applyTokenFilter() {
+ state.ui.tokenFilter.status = DOM.tokenFilterStatus ? DOM.tokenFilterStatus.value : 'all';
+ state.ui.tokenFilter.keyword = DOM.tokenFilterKeyword ? DOM.tokenFilterKeyword.value.trim() : '';
+ renderTokenList();
+}
+
+function resetTokenFilter() {
+ state.ui.tokenFilter.status = 'all';
+ state.ui.tokenFilter.keyword = '';
+ if (DOM.tokenFilterStatus) DOM.tokenFilterStatus.value = 'all';
+ if (DOM.tokenFilterKeyword) DOM.tokenFilterKeyword.value = '';
+ renderTokenList();
+}
+
+function renderTokenItem(t) {
+ const platforms = getTokenUploadedPlatforms(t);
+ const uploaded = platforms.length > 0;
+ const platformBadges = platforms.length > 0
+ ? platforms.map((p) => `${p === 'cpa' ? 'CPA' : 'Sub2Api'}`).join('')
+ : '未上传';
+ const expiredStr = formatTime(t.expired);
+ const tokenPayload = encodeURIComponent(JSON.stringify(t.content || {}));
+ const filePayload = encodeURIComponent(t.filename || '');
+ return `
+
+
+
+ ${escapeHtml(t.email || t.filename)}
+
+
${platformBadges}
+
过期: ${expiredStr}
+
+
+
+
+
+
`;
+}
+
+function formatTime(timeStr) {
+ if (!timeStr) return '未知';
+ try {
+ const d = new Date(timeStr);
+ if (isNaN(d.getTime())) return timeStr;
+ const pad = n => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ } catch { return timeStr; }
+}
+
+async function copyToken(jsonStr) {
+ const ok = await copyText(jsonStr);
+ showToast(ok ? 'Token 已复制到剪贴板' : '复制失败', ok ? 'success' : 'error');
+}
+
+async function copyText(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ try { await navigator.clipboard.writeText(text); return true; } catch { }
+ }
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
+ document.body.appendChild(ta);
+ ta.focus(); ta.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(ta);
+ return ok;
+ } catch { return false; }
+}
+
+async function copyAllRt() {
+ try {
+ const visibleTokens = getFilteredTokens(state.ui.tokens || []);
+ const rts = visibleTokens.map(t => (t.content || {}).refresh_token || '').filter(Boolean);
+ if (rts.length === 0) { showToast('没有可用的 Refresh Token', 'error'); return; }
+ const ok = await copyText(rts.join('\n'));
+ showToast(ok ? `已复制 ${rts.length} 个 RT(当前筛选)` : '复制失败', ok ? 'success' : 'error');
+ } catch (e) { showToast('复制失败: ' + e.message, 'error'); }
+}
+
+function downloadBlob(filename, blob) {
+ const link = document.createElement('a');
+ const url = URL.createObjectURL(blob);
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
+
+function exportLocalTokens() {
+ try {
+ const visibleTokens = getFilteredTokens(state.ui.tokens || []);
+ if (visibleTokens.length === 0) {
+ showToast('没有可导出的 Token(当前筛选)', 'error');
+ return;
+ }
+
+ const exportPayload = {
+ exported_at: new Date().toISOString(),
+ total: visibleTokens.length,
+ filter: {
+ status: state.ui.tokenFilter.status || 'all',
+ keyword: state.ui.tokenFilter.keyword || '',
+ },
+ tokens: visibleTokens.map((t) => ({
+ filename: t.filename || '',
+ email: t.email || '',
+ uploaded_platforms: getTokenUploadedPlatforms(t),
+ content: t.content || {},
+ })),
+ };
+
+ const status = String(state.ui.tokenFilter.status || 'all').replace(/[^a-z0-9_-]/gi, '_');
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const filename = `local_tokens_${status}_${stamp}.json`;
+ const blob = new Blob([JSON.stringify(exportPayload, null, 2)], {
+ type: 'application/json;charset=utf-8',
+ });
+ downloadBlob(filename, blob);
+ showToast(`已导出 ${visibleTokens.length} 条 Token(当前筛选)`, 'success');
+ } catch (e) {
+ showToast('导出失败: ' + e.message, 'error');
+ }
+}
+
+async function deleteToken(filename) {
+ if (!confirm(`确认删除 ${filename}?`)) return;
+ try {
+ const res = await fetch(`/api/tokens/${encodeURIComponent(filename)}`, { method: 'DELETE' });
+ if (res.ok) { showToast('已删除', 'info'); loadTokens(); }
+ else showToast('删除失败', 'error');
+ } catch { showToast('删除请求失败', 'error'); }
+}
+
+function isSub2ApiAbnormalStatus(status) {
+ return SUB2API_ABNORMAL_STATUSES.has(String(status || '').trim().toLowerCase());
+}
+
+function getSub2ApiMaintainActionsFromForm() {
+ return {
+ refresh_abnormal_accounts: DOM.sub2apiMaintainRefreshAbnormal ? DOM.sub2apiMaintainRefreshAbnormal.checked : true,
+ delete_abnormal_accounts: DOM.sub2apiMaintainDeleteAbnormal ? DOM.sub2apiMaintainDeleteAbnormal.checked : true,
+ dedupe_duplicate_accounts: DOM.sub2apiMaintainDedupe ? DOM.sub2apiMaintainDedupe.checked : true,
+ };
+}
+
+function describeSub2ApiMaintainActions(actions = getSub2ApiMaintainActionsFromForm()) {
+ const labels = [];
+ if (actions.refresh_abnormal_accounts) labels.push('异常测活');
+ if (actions.delete_abnormal_accounts) labels.push('异常清理');
+ if (actions.dedupe_duplicate_accounts) labels.push('重复清理');
+ return labels.length ? labels.join('、') : '无动作';
+}
+
+function getFilteredSub2ApiAccounts(accounts = state.ui.sub2apiAccounts || []) {
+ return Array.isArray(accounts) ? accounts : [];
+}
+
+function applySub2ApiAccountFilter() {
+ state.ui.sub2apiAccountFilter.status = DOM.sub2apiAccountStatusFilter ? DOM.sub2apiAccountStatusFilter.value : 'all';
+ state.ui.sub2apiAccountFilter.keyword = DOM.sub2apiAccountKeyword ? DOM.sub2apiAccountKeyword.value.trim() : '';
+ state.ui.sub2apiAccountPager.page = 1;
+ loadSub2ApiAccounts();
+}
+
+function resetSub2ApiAccountFilter() {
+ state.ui.sub2apiAccountFilter.status = 'all';
+ state.ui.sub2apiAccountFilter.keyword = '';
+ if (DOM.sub2apiAccountStatusFilter) DOM.sub2apiAccountStatusFilter.value = 'all';
+ if (DOM.sub2apiAccountKeyword) DOM.sub2apiAccountKeyword.value = '';
+ state.ui.sub2apiAccountPager.page = 1;
+ loadSub2ApiAccounts();
+}
+
+async function loadSub2ApiAccounts({ silent = false } = {}) {
+ if (!DOM.sub2apiAccountList || state.ui.sub2apiAccountsLoading) return;
+ state.ui.sub2apiAccountsLoading = true;
+ if (!silent && DOM.sub2apiAccountActionStatus && !state.ui.sub2apiAccountActionBusy) {
+ DOM.sub2apiAccountActionStatus.textContent = '正在加载 Sub2Api 账号列表...';
+ }
+ try {
+ const params = new URLSearchParams({
+ page: String(state.ui.sub2apiAccountPager.page || 1),
+ page_size: String(state.ui.sub2apiAccountPager.pageSize || 20),
+ status: String(state.ui.sub2apiAccountFilter.status || 'all'),
+ keyword: String(state.ui.sub2apiAccountFilter.keyword || ''),
+ });
+ const res = await fetch(`/api/sub2api/accounts?${params.toString()}`);
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.detail || 'Sub2Api 账号列表加载失败');
+
+ if (!data.configured) {
+ state.ui.sub2apiAccounts = [];
+ state.ui.selectedSub2ApiAccountIds.clear();
+ state.ui.sub2apiAccountPager.total = 0;
+ state.ui.sub2apiAccountPager.filteredTotal = 0;
+ state.ui.sub2apiAccountPager.totalPages = 1;
+ state.ui.sub2apiAccountPager.page = 1;
+ renderSub2ApiAccountList('请先完成 Sub2Api 平台配置');
+ if (DOM.sub2apiAccountActionStatus && !state.ui.sub2apiAccountActionBusy) {
+ DOM.sub2apiAccountActionStatus.textContent = data.error || 'Sub2Api 未配置';
+ }
+ return;
+ }
+
+ state.ui.sub2apiAccounts = Array.isArray(data.items) ? data.items : [];
+ state.ui.sub2apiAccountPager.page = parseInt(data.page, 10) || 1;
+ state.ui.sub2apiAccountPager.pageSize = parseInt(data.page_size, 10) || state.ui.sub2apiAccountPager.pageSize || 20;
+ state.ui.sub2apiAccountPager.total = parseInt(data.total, 10) || 0;
+ state.ui.sub2apiAccountPager.filteredTotal = parseInt(data.filtered_total, 10) || 0;
+ state.ui.sub2apiAccountPager.totalPages = parseInt(data.total_pages, 10) || 1;
+ renderSub2ApiAccountList();
+ if (!silent && DOM.sub2apiAccountActionStatus && !state.ui.sub2apiAccountActionBusy) {
+ DOM.sub2apiAccountActionStatus.textContent = `已加载第 ${state.ui.sub2apiAccountPager.page}/${state.ui.sub2apiAccountPager.totalPages} 页,共 ${state.ui.sub2apiAccountPager.filteredTotal} 个账号`;
+ }
+ } catch (e) {
+ state.ui.sub2apiAccounts = [];
+ state.ui.sub2apiAccountPager.filteredTotal = 0;
+ state.ui.sub2apiAccountPager.totalPages = 1;
+ renderSub2ApiAccountList('Sub2Api 账号列表加载失败');
+ if (DOM.sub2apiAccountActionStatus && !state.ui.sub2apiAccountActionBusy) {
+ DOM.sub2apiAccountActionStatus.textContent = '账号列表加载失败: ' + e.message;
+ }
+ } finally {
+ state.ui.sub2apiAccountsLoading = false;
+ refreshSub2ApiSelectionState();
+ }
+}
+
+function updateSub2ApiPagerUI() {
+ const pager = state.ui.sub2apiAccountPager || {};
+ const page = pager.page || 1;
+ const totalPages = pager.totalPages || 1;
+ const pageSize = pager.pageSize || 20;
+ if (DOM.sub2apiAccountPageInfo) {
+ DOM.sub2apiAccountPageInfo.textContent = `第 ${page}/${totalPages} 页 · 每页 ${pageSize} 条`;
+ }
+ if (DOM.sub2apiAccountPageSize && String(DOM.sub2apiAccountPageSize.value) !== String(pageSize)) {
+ DOM.sub2apiAccountPageSize.value = String(pageSize);
+ }
+ if (DOM.sub2apiAccountPrevBtn) DOM.sub2apiAccountPrevBtn.disabled = state.ui.sub2apiAccountActionBusy || page <= 1;
+ if (DOM.sub2apiAccountNextBtn) DOM.sub2apiAccountNextBtn.disabled = state.ui.sub2apiAccountActionBusy || page >= totalPages;
+}
+
+function changeSub2ApiAccountPage(delta) {
+ const nextPage = (state.ui.sub2apiAccountPager.page || 1) + delta;
+ const totalPages = state.ui.sub2apiAccountPager.totalPages || 1;
+ if (nextPage < 1 || nextPage > totalPages) return;
+ state.ui.sub2apiAccountPager.page = nextPage;
+ loadSub2ApiAccounts();
+}
+
+function changeSub2ApiAccountPageSize() {
+ const nextPageSize = DOM.sub2apiAccountPageSize ? parseInt(DOM.sub2apiAccountPageSize.value, 10) || 20 : 20;
+ state.ui.sub2apiAccountPager.pageSize = nextPageSize;
+ state.ui.sub2apiAccountPager.page = 1;
+ loadSub2ApiAccounts();
+}
+
+function renderSub2ApiAccountList(emptyMessage = '') {
+ const pageAccounts = getFilteredSub2ApiAccounts(state.ui.sub2apiAccounts || []);
+ const pager = state.ui.sub2apiAccountPager || {};
+ if (!DOM.sub2apiAccountList) return;
+ if (pageAccounts.length === 0) {
+ const hasAny = (pager.filteredTotal || 0) > 0 || (pager.total || 0) > 0;
+ const msg = emptyMessage || (!hasAny ? '暂无 Sub2Api 账号' : '暂无符合筛选条件的账号');
+ DOM.sub2apiAccountList.innerHTML = ``;
+ updateSub2ApiPagerUI();
+ refreshSub2ApiSelectionState();
+ return;
+ }
+ DOM.sub2apiAccountList.innerHTML = pageAccounts.map(account => renderSub2ApiAccountItem(account)).join('');
+ updateSub2ApiPagerUI();
+ refreshSub2ApiSelectionState();
+}
+
+function renderSub2ApiAccountItem(account) {
+ const accountId = Number(account.id || 0);
+ const email = account.email || account.name || `账号 ${accountId}`;
+ const status = String(account.status || 'unknown').trim().toLowerCase();
+ const isAbnormal = isSub2ApiAbnormalStatus(status);
+ const selected = state.ui.selectedSub2ApiAccountIds.has(accountId);
+ const statusLabel = {
+ error: '异常',
+ disabled: '禁用',
+ normal: '正常',
+ active: '正常',
+ ok: '正常',
+ unknown: '未知',
+ }[status] || status || '未知';
+ const statusClass = status === 'disabled' ? 'warn' : (isAbnormal ? 'danger' : 'ok');
+ const duplicateBadges = [];
+ if (account.is_duplicate) {
+ duplicateBadges.push(`重复 ${account.duplicate_group_size || 0}`);
+ if (account.duplicate_keep) duplicateBadges.push('保留');
+ if (account.duplicate_delete_candidate) duplicateBadges.push('候删');
+ }
+ return `
+
+
+
+
+ ${escapeHtml(email)}
+ ${escapeHtml(statusLabel)}
+ ${duplicateBadges.join('')}
+
+
ID: ${accountId} · 更新时间: ${escapeHtml(formatTime(account.updated_at))}
+
+
+
+
+
+
`;
+}
+
+function updateHeaderLocalTokens(tokens = state.ui.tokens || []) {
+ const allTokens = Array.isArray(tokens) ? tokens : [];
+ const total = allTokens.length;
+ const now = Date.now();
+ const validCount = allTokens.filter((token) => {
+ const timeStr = token && token.expired;
+ if (!timeStr) return true;
+ const timestamp = new Date(timeStr).getTime();
+ return !Number.isNaN(timestamp) ? timestamp > now : true;
+ }).length;
+ const fillPct = total > 0 ? Math.round((validCount / total) * 100) : 0;
+ const stateName = total === 0 ? 'idle' : (fillPct >= 85 ? 'ok' : fillPct >= 50 ? 'warn' : 'danger');
+
+ if (DOM.headerLocalTokenLabel) DOM.headerLocalTokenLabel.textContent = `${validCount} / ${total}`;
+ if (DOM.headerLocalTokenDelta) DOM.headerLocalTokenDelta.textContent = `${fillPct}%`;
+ if (DOM.headerLocalTokenBar) {
+ DOM.headerLocalTokenBar.style.width = `${Math.min(100, Math.max(fillPct, 0))}%`;
+ DOM.headerLocalTokenBar.className = `pool-chip-fill ${stateName === 'idle' ? '' : stateName}`.trim();
+ }
+ setHeaderChipStatus(DOM.headerLocalTokenChip, stateName);
+ if (DOM.headerLocalTokenDelta) {
+ DOM.headerLocalTokenDelta.className = `pool-chip-delta ${stateName === 'idle' ? '' : stateName}`.trim();
+ }
+}
+
+function refreshSub2ApiSelectionState() {
+ const visibleAccounts = state.ui.sub2apiAccounts || [];
+ const visibleIds = visibleAccounts
+ .map(item => item.id)
+ .filter(id => Number.isInteger(id) && id > 0);
+ const selectedVisible = visibleIds.filter(id => state.ui.selectedSub2ApiAccountIds.has(id)).length;
+ const selectedTotal = Array.from(state.ui.selectedSub2ApiAccountIds).length;
+
+ if (DOM.sub2apiAccountSelection) {
+ DOM.sub2apiAccountSelection.textContent = `已选 ${selectedTotal} 个,当前页 ${visibleIds.length} 个`;
+ }
+ if (DOM.sub2apiAccountSelectAll) {
+ const allSelected = visibleIds.length > 0 && selectedVisible === visibleIds.length;
+ DOM.sub2apiAccountSelectAll.checked = allSelected;
+ DOM.sub2apiAccountSelectAll.indeterminate = selectedVisible > 0 && selectedVisible < visibleIds.length;
+ }
+}
+
+function toggleSelectAllSub2ApiAccounts() {
+ const visibleAccounts = state.ui.sub2apiAccounts || [];
+ const shouldSelect = !!(DOM.sub2apiAccountSelectAll && DOM.sub2apiAccountSelectAll.checked);
+ visibleAccounts.forEach((account) => {
+ const accountId = Number(account.id || 0);
+ if (!Number.isInteger(accountId) || accountId <= 0) return;
+ if (shouldSelect) state.ui.selectedSub2ApiAccountIds.add(accountId);
+ else state.ui.selectedSub2ApiAccountIds.delete(accountId);
+ });
+ renderSub2ApiAccountList();
+}
+
+function getSelectedSub2ApiAccountIds() {
+ return Array.from(state.ui.selectedSub2ApiAccountIds)
+ .filter(id => Number.isInteger(id) && id > 0)
+ .sort((a, b) => a - b);
+}
+
+function setSub2ApiAccountBusy(busy) {
+ state.ui.sub2apiAccountActionBusy = busy;
+ [
+ DOM.sub2apiAccountApplyBtn,
+ DOM.sub2apiAccountResetBtn,
+ DOM.sub2apiAccountProbeBtn,
+ DOM.sub2apiAccountExceptionBtn,
+ DOM.sub2apiDuplicateScanBtn,
+ DOM.sub2apiDuplicateCleanBtn,
+ DOM.sub2apiAccountDeleteBtn,
+ DOM.sub2apiAccountPrevBtn,
+ DOM.sub2apiAccountNextBtn,
+ ].forEach((btn) => {
+ if (btn) btn.disabled = busy;
+ });
+ if (DOM.sub2apiAccountSelectAll) DOM.sub2apiAccountSelectAll.disabled = busy;
+ if (DOM.sub2apiAccountPageSize) DOM.sub2apiAccountPageSize.disabled = busy;
+ if (!busy) updateSub2ApiPagerUI();
+}
+
+async function runSub2ApiAccountProbe(accountIds, label = '选中账号') {
+ if (state.ui.sub2apiAccountActionBusy) return;
+ const ids = (accountIds || []).filter(id => Number.isInteger(id) && id > 0);
+ if (!ids.length) {
+ showToast('请先选择至少一个账号', 'error');
+ return;
+ }
+
+ setSub2ApiAccountBusy(true);
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = `正在测活 ${ids.length} 个账号...`;
+ try {
+ const res = await fetch('/api/sub2api/accounts/probe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ account_ids: ids, timeout: 30 }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.detail || '账号测活失败');
+ const msg = `${label}: 刷新成功 ${data.refreshed_ok || 0}, 恢复 ${data.recovered || 0}, 仍异常 ${data.still_abnormal || 0}`;
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = msg;
+ showToast(msg, 'success');
+ await loadSub2ApiAccounts({ silent: true });
+ pollSub2ApiPoolStatus();
+ } catch (e) {
+ const msg = '账号测活失败: ' + e.message;
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = msg;
+ showToast(msg, 'error');
+ } finally {
+ setSub2ApiAccountBusy(false);
+ }
+}
+
+async function triggerSelectedSub2ApiProbe() {
+ await runSub2ApiAccountProbe(getSelectedSub2ApiAccountIds());
+}
+
+async function runSub2ApiExceptionHandling(accountIds = []) {
+ if (state.ui.sub2apiAccountActionBusy) return;
+ const ids = (accountIds || []).filter(id => Number.isInteger(id) && id > 0);
+
+ setSub2ApiAccountBusy(true);
+ if (DOM.sub2apiAccountActionStatus) {
+ DOM.sub2apiAccountActionStatus.textContent = ids.length
+ ? `正在处理 ${ids.length} 个异常账号...`
+ : '正在处理整池异常账号...';
+ }
+ try {
+ const res = await fetch('/api/sub2api/accounts/handle-exception', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ account_ids: ids, timeout: 30, delete_unresolved: true }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.detail || '异常账号处理失败');
+ const msg = `异常处理完成: 目标 ${data.targeted || 0}, 恢复 ${data.recovered || 0}, 删除 ${data.deleted_ok || 0}, 失败 ${data.deleted_fail || 0}`;
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = msg;
+ showToast(msg, 'success');
+ await loadSub2ApiAccounts({ silent: true });
+ pollSub2ApiPoolStatus();
+ } catch (e) {
+ const msg = '异常账号处理失败: ' + e.message;
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = msg;
+ showToast(msg, 'error');
+ } finally {
+ setSub2ApiAccountBusy(false);
+ }
+}
+
+async function triggerSub2ApiExceptionHandling() {
+ const ids = getSelectedSub2ApiAccountIds();
+ if (ids.length) {
+ if (!confirm(`确认处理 ${ids.length} 个已选账号?系统会先测活,仍异常的账号会被删除。`)) return;
+ await runSub2ApiExceptionHandling(ids);
+ return;
+ }
+ if (!confirm('未选择账号,将处理整个 Sub2Api 池中的异常账号。是否继续?')) return;
+ await runSub2ApiExceptionHandling([]);
+}
+
+async function runSub2ApiAccountDelete(accountIds, label = '选中账号', requireConfirm = true) {
+ if (state.ui.sub2apiAccountActionBusy) return;
+ const ids = (accountIds || []).filter(id => Number.isInteger(id) && id > 0);
+ if (!ids.length) {
+ showToast('请先选择至少一个账号', 'error');
+ return;
+ }
+ if (requireConfirm && !confirm(`确认删除 ${label}(共 ${ids.length} 个)?`)) return;
+
+ setSub2ApiAccountBusy(true);
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = `正在删除 ${ids.length} 个账号...`;
+ try {
+ const res = await fetch('/api/sub2api/accounts/delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ account_ids: ids, timeout: 20 }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.detail || '批量删除失败');
+ ids.forEach(id => state.ui.selectedSub2ApiAccountIds.delete(id));
+ const msg = `批量删除完成: 成功 ${data.deleted_ok || 0}, 失败 ${data.deleted_fail || 0}`;
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = msg;
+ showToast(msg, 'success');
+ await loadSub2ApiAccounts({ silent: true });
+ pollSub2ApiPoolStatus();
+ } catch (e) {
+ const msg = '批量删除失败: ' + e.message;
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = msg;
+ showToast(msg, 'error');
+ } finally {
+ setSub2ApiAccountBusy(false);
+ }
+}
+
+async function triggerSelectedSub2ApiDelete() {
+ await runSub2ApiAccountDelete(getSelectedSub2ApiAccountIds());
+}
+
+async function previewSub2ApiDuplicates() {
+ if (state.ui.sub2apiAccountActionBusy) return;
+ setSub2ApiAccountBusy(true);
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = '正在检测重复账号...';
+ try {
+ const res = await fetch('/api/sub2api/pool/dedupe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ dry_run: true, timeout: 20 }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.detail || '重复账号检测失败');
+ const msg = `重复预检完成: 重复组 ${data.duplicate_groups || 0}, 重复账号 ${data.duplicate_accounts || 0}, 可删 ${data.to_delete || 0}`;
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = msg;
+ showToast(msg, 'success');
+ await loadSub2ApiAccounts({ silent: true });
+ } catch (e) {
+ const msg = '重复账号检测失败: ' + e.message;
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = msg;
+ showToast(msg, 'error');
+ } finally {
+ setSub2ApiAccountBusy(false);
+ }
+}
+
+async function cleanupSub2ApiDuplicates() {
+ if (state.ui.sub2apiAccountActionBusy) return;
+ if (!confirm('确认清理 Sub2Api 中的重复账号?系统会保留每组中更新时间最新的账号。')) return;
+ setSub2ApiAccountBusy(true);
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = '正在清理重复账号...';
+ try {
+ const res = await fetch('/api/sub2api/pool/dedupe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ dry_run: false, timeout: 20 }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.detail || '重复账号清理失败');
+ const msg = `重复清理完成: 删除成功 ${data.deleted_ok || 0}, 删除失败 ${data.deleted_fail || 0}`;
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = msg;
+ showToast(msg, 'success');
+ await loadSub2ApiAccounts({ silent: true });
+ pollSub2ApiPoolStatus();
+ } catch (e) {
+ const msg = '重复账号清理失败: ' + e.message;
+ if (DOM.sub2apiAccountActionStatus) DOM.sub2apiAccountActionStatus.textContent = msg;
+ showToast(msg, 'error');
+ } finally {
+ setSub2ApiAccountBusy(false);
+ }
+}
+
+// ==========================================
+// Sub2Api 同步配置
+// ==========================================
+async function loadSyncConfig() {
+ if (DOM.syncStatus) DOM.syncStatus.textContent = '';
+ try {
+ const res = await fetch('/api/sync-config');
+ const cfg = await res.json();
+ DOM.sub2apiBaseUrl.value = cfg.base_url || '';
+ if (cfg.email) DOM.sub2apiEmail.value = cfg.email;
+ DOM.autoSyncCheck.checked = !!cfg.auto_sync;
+ if (DOM.uploadMode) DOM.uploadMode.value = cfg.upload_mode || 'snapshot';
+ if (DOM.uploadModeStatus) DOM.uploadModeStatus.textContent = '';
+ if (DOM.sub2apiMinCandidates) DOM.sub2apiMinCandidates.value = cfg.sub2api_min_candidates || 200;
+ if (DOM.sub2apiInterval) DOM.sub2apiInterval.value = cfg.sub2api_maintain_interval_minutes || 30;
+ if (DOM.sub2apiAutoMaintain) DOM.sub2apiAutoMaintain.checked = !!cfg.sub2api_auto_maintain;
+ const maintainActions = cfg.sub2api_maintain_actions || {};
+ if (DOM.sub2apiMaintainRefreshAbnormal) {
+ DOM.sub2apiMaintainRefreshAbnormal.checked = maintainActions.refresh_abnormal_accounts !== false;
+ }
+ if (DOM.sub2apiMaintainDeleteAbnormal) {
+ DOM.sub2apiMaintainDeleteAbnormal.checked = maintainActions.delete_abnormal_accounts !== false;
+ }
+ if (DOM.sub2apiMaintainDedupe) {
+ DOM.sub2apiMaintainDedupe.checked = maintainActions.dedupe_duplicate_accounts !== false;
+ }
+ if (DOM.multithreadCheck) DOM.multithreadCheck.checked = !!cfg.multithread;
+ if (DOM.threadCountInput) DOM.threadCountInput.value = cfg.thread_count || 3;
+ if (cfg.proxy && DOM.proxyInput) DOM.proxyInput.value = cfg.proxy;
+ if (DOM.autoRegisterCheck) DOM.autoRegisterCheck.checked = !!cfg.auto_register;
+ if (DOM.syncStatus) DOM.syncStatus.textContent = '';
+ } catch { }
+}
+
+async function loadProxyPoolConfig() {
+ try {
+ const res = await fetch('/api/proxy-pool/config');
+ const cfg = await res.json();
+ if (DOM.proxyPoolEnabled) DOM.proxyPoolEnabled.checked = !!cfg.proxy_pool_enabled;
+ if (DOM.proxyPoolApiUrl) DOM.proxyPoolApiUrl.value = cfg.proxy_pool_api_url || 'https://zenproxy.top/api/fetch';
+ if (DOM.proxyPoolAuthMode) DOM.proxyPoolAuthMode.value = cfg.proxy_pool_auth_mode || 'query';
+ if (DOM.proxyPoolCount) DOM.proxyPoolCount.value = cfg.proxy_pool_count || 1;
+ if (DOM.proxyPoolCountry) DOM.proxyPoolCountry.value = (cfg.proxy_pool_country || 'US').toUpperCase();
+ if (DOM.proxyPoolApiKey) {
+ DOM.proxyPoolApiKey.value = '';
+ DOM.proxyPoolApiKey.placeholder = cfg.proxy_pool_api_key_preview
+ ? `已保存: ${cfg.proxy_pool_api_key_preview}`
+ : '请输入代理池 API Key';
+ }
+ if (DOM.proxyPoolStatus) DOM.proxyPoolStatus.textContent = '';
+ } catch { }
+}
+
+async function saveProxyPoolConfig() {
+ if (!DOM.proxyPoolSaveBtn) return;
+ const payload = {
+ proxy_pool_enabled: DOM.proxyPoolEnabled ? DOM.proxyPoolEnabled.checked : true,
+ proxy_pool_api_url: DOM.proxyPoolApiUrl ? DOM.proxyPoolApiUrl.value.trim() : 'https://zenproxy.top/api/fetch',
+ proxy_pool_auth_mode: DOM.proxyPoolAuthMode ? DOM.proxyPoolAuthMode.value : 'query',
+ proxy_pool_api_key: DOM.proxyPoolApiKey ? DOM.proxyPoolApiKey.value.trim() : '',
+ proxy_pool_count: DOM.proxyPoolCount ? (parseInt(DOM.proxyPoolCount.value, 10) || 1) : 1,
+ proxy_pool_country: DOM.proxyPoolCountry ? DOM.proxyPoolCountry.value.trim().toUpperCase() : 'US',
+ };
+ if (!payload.proxy_pool_api_url) {
+ showToast('请填写代理池 API 地址', 'error');
+ return;
+ }
+ if (payload.proxy_pool_count < 1) payload.proxy_pool_count = 1;
+ if (!payload.proxy_pool_country) payload.proxy_pool_country = 'US';
+
+ DOM.proxyPoolSaveBtn.disabled = true;
+ const oldText = DOM.proxyPoolSaveBtn.textContent;
+ DOM.proxyPoolSaveBtn.textContent = '保存中...';
+ if (DOM.proxyPoolStatus) DOM.proxyPoolStatus.textContent = '正在保存代理池配置...';
+ try {
+ const res = await fetch('/api/proxy-pool/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ const msg = data.detail || '保存失败';
+ if (DOM.proxyPoolStatus) DOM.proxyPoolStatus.textContent = msg;
+ showToast(msg, 'error');
+ return;
+ }
+ if (DOM.proxyPoolApiKey && payload.proxy_pool_api_key) {
+ DOM.proxyPoolApiKey.value = '';
+ DOM.proxyPoolApiKey.placeholder = `已保存: ${payload.proxy_pool_api_key.slice(0, 8)}...`;
+ }
+ const msg = '代理池配置已保存';
+ if (DOM.proxyPoolStatus) DOM.proxyPoolStatus.textContent = msg;
+ showToast(msg, 'success');
+ } catch (e) {
+ const msg = '请求失败: ' + e.message;
+ if (DOM.proxyPoolStatus) DOM.proxyPoolStatus.textContent = msg;
+ showToast(msg, 'error');
+ } finally {
+ DOM.proxyPoolSaveBtn.disabled = false;
+ DOM.proxyPoolSaveBtn.textContent = oldText || '保存代理池配置';
+ }
+}
+
+async function saveSyncConfig() {
+ const base_url = DOM.sub2apiBaseUrl.value.trim();
+ const email = DOM.sub2apiEmail.value.trim();
+ const password = DOM.sub2apiPassword.value.trim();
+ const auto_sync = !!DOM.autoSyncCheck.checked;
+ const upload_mode = DOM.uploadMode ? DOM.uploadMode.value : 'snapshot';
+ const sub2api_min_candidates = parseInt(DOM.sub2apiMinCandidates.value) || 200;
+ const sub2api_auto_maintain = DOM.sub2apiAutoMaintain.checked;
+ const sub2api_maintain_interval_minutes = parseInt(DOM.sub2apiInterval.value) || 30;
+ const sub2api_maintain_actions = getSub2ApiMaintainActionsFromForm();
+ const multithread = DOM.multithreadCheck ? DOM.multithreadCheck.checked : false;
+ const thread_count = DOM.threadCountInput ? parseInt(DOM.threadCountInput.value) || 3 : 3;
+ const auto_register = DOM.autoRegisterCheck ? DOM.autoRegisterCheck.checked : false;
+
+ if (!base_url) { showToast('请填写平台地址', 'error'); return; }
+ if (!email) { showToast('请填写邮箱', 'error'); return; }
+
+ DOM.saveSyncConfigBtn.disabled = true;
+ DOM.saveSyncConfigBtn.textContent = '验证中...';
+ DOM.syncStatus.textContent = '正在验证账号密码...';
+ try {
+ const res = await fetch('/api/sync-config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ base_url, email, password, account_name: 'AutoReg', auto_sync,
+ upload_mode,
+ sub2api_min_candidates, sub2api_auto_maintain, sub2api_maintain_interval_minutes,
+ sub2api_maintain_actions,
+ multithread, thread_count, auto_register,
+ }),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ showToast('验证通过,配置已保存', 'success');
+ DOM.syncStatus.textContent = '验证通过,配置已保存';
+ pollSub2ApiPoolStatus();
+ loadSub2ApiAccounts();
+ } else {
+ showToast(data.detail || '验证失败', 'error');
+ DOM.syncStatus.textContent = data.detail || '验证失败';
+ }
+ } catch (e) {
+ showToast('请求失败: ' + e.message, 'error');
+ DOM.syncStatus.textContent = '请求失败: ' + e.message;
+ } finally {
+ DOM.saveSyncConfigBtn.disabled = false;
+ DOM.saveSyncConfigBtn.textContent = '保存';
+ }
+}
+
+async function saveUploadMode() {
+ const upload_mode = DOM.uploadMode ? DOM.uploadMode.value : 'snapshot';
+ if (!DOM.uploadModeSaveBtn) return;
+ DOM.uploadModeSaveBtn.disabled = true;
+ const oldText = DOM.uploadModeSaveBtn.textContent;
+ DOM.uploadModeSaveBtn.textContent = '保存中...';
+ if (DOM.uploadModeStatus) DOM.uploadModeStatus.textContent = '正在保存策略...';
+ try {
+ const res = await fetch('/api/upload-mode', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ upload_mode }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ const msg = data.detail || '保存失败';
+ showToast(msg, 'error');
+ if (DOM.uploadModeStatus) DOM.uploadModeStatus.textContent = msg;
+ return;
+ }
+ const label = upload_mode === 'decoupled' ? '双平台同传(单账号双上传)' : '串行补平台(先CPA后Sub2Api)';
+ showToast('上传策略已保存:' + label, 'success');
+ if (DOM.uploadModeStatus) DOM.uploadModeStatus.textContent = '已保存:' + label;
+ } catch (e) {
+ showToast('请求失败: ' + e.message, 'error');
+ if (DOM.uploadModeStatus) DOM.uploadModeStatus.textContent = '请求失败: ' + e.message;
+ } finally {
+ DOM.uploadModeSaveBtn.disabled = false;
+ DOM.uploadModeSaveBtn.textContent = oldText || '保存策略';
+ }
+}
+
+async function batchSync() {
+ const btn = DOM.poolPwSyncBtn;
+ if (!btn) return;
+ btn.disabled = true;
+ btn.textContent = '导入中...';
+ showToast('批量导入开始', 'info');
+ try {
+ const res = await fetch('/api/sync-batch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ filenames: [] }),
+ });
+ const data = await res.json();
+ if (!res.ok) { showToast(data.detail || '导入失败', 'error'); return; }
+ const msg = `导入完成:共 ${data.total},成功 ${data.ok},失败 ${data.fail}`;
+ showToast(msg, data.fail > 0 ? 'info' : 'success');
+ } catch (e) {
+ showToast('导入失败: ' + e.message, 'error');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '批量导入';
+ }
+}
+
+// ==========================================
+// CPA 配置
+// ==========================================
+async function loadPoolConfig() {
+ try {
+ const res = await fetch('/api/pool/config');
+ const cfg = await res.json();
+ DOM.cpaBaseUrl.value = cfg.cpa_base_url || '';
+ DOM.cpaMinCandidates.value = cfg.min_candidates || 800;
+ DOM.cpaUsedPercent.value = cfg.used_percent_threshold || 95;
+ DOM.cpaAutoMaintain.checked = !!cfg.auto_maintain;
+ DOM.cpaInterval.value = cfg.maintain_interval_minutes || 30;
+ if (DOM.cpaStatus) DOM.cpaStatus.textContent = '';
+ } catch { }
+}
+
+async function savePoolConfig() {
+ const payload = {
+ cpa_base_url: DOM.cpaBaseUrl.value.trim(),
+ cpa_token: DOM.cpaToken.value.trim(),
+ min_candidates: parseInt(DOM.cpaMinCandidates.value) || 800,
+ used_percent_threshold: parseInt(DOM.cpaUsedPercent.value) || 95,
+ auto_maintain: DOM.cpaAutoMaintain.checked,
+ maintain_interval_minutes: parseInt(DOM.cpaInterval.value) || 30,
+ };
+ DOM.cpaSaveBtn.disabled = true;
+ try {
+ const res = await fetch('/api/pool/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (res.ok) {
+ showToast('CPA 配置已保存', 'success');
+ DOM.cpaStatus.textContent = '配置已保存';
+ pollPoolStatus();
+ } else {
+ const data = await res.json();
+ showToast(data.detail || '保存失败', 'error');
+ DOM.cpaStatus.textContent = data.detail || '保存失败';
+ }
+ } catch (e) {
+ showToast('请求失败: ' + e.message, 'error');
+ DOM.cpaStatus.textContent = '请求失败';
+ } finally {
+ DOM.cpaSaveBtn.disabled = false;
+ }
+}
+
+async function testCpaConnection() {
+ DOM.cpaTestBtn.disabled = true;
+ DOM.cpaStatus.textContent = '测试中...';
+ try {
+ const res = await fetch('/api/pool/check', { method: 'POST' });
+ const data = await res.json();
+ if (data.ok) {
+ DOM.cpaStatus.textContent = data.message || '连接成功';
+ showToast('CPA 连接成功', 'success');
+ } else {
+ DOM.cpaStatus.textContent = data.message || data.detail || '连接失败';
+ showToast('CPA 连接失败', 'error');
+ }
+ } catch (e) {
+ DOM.cpaStatus.textContent = '请求失败: ' + e.message;
+ } finally {
+ DOM.cpaTestBtn.disabled = false;
+ }
+}
+
+// ==========================================
+// 池状态轮询
+// ==========================================
+async function pollPoolStatus() {
+ try {
+ const res = await fetch('/api/pool/status');
+ const data = await res.json();
+
+ if (!data.configured) {
+ if (DOM.poolTotal) DOM.poolTotal.textContent = '--';
+ if (DOM.poolCandidates) DOM.poolCandidates.textContent = '--';
+ if (DOM.poolError) DOM.poolError.textContent = '--';
+ if (DOM.poolThreshold) DOM.poolThreshold.textContent = '--';
+ if (DOM.poolPercent) DOM.poolPercent.textContent = '--';
+ updateHeaderCpa(null);
+ return;
+ }
+
+ const candidates = data.candidates || 0;
+ const errorCount = data.error_count || 0;
+ const threshold = data.threshold || 0;
+ const fillPct = threshold > 0 ? Math.round(candidates / threshold * 100) : 100;
+
+ if (DOM.poolTotal) DOM.poolTotal.textContent = data.total || 0;
+ if (DOM.poolCandidates) DOM.poolCandidates.textContent = candidates;
+ if (DOM.poolError) {
+ DOM.poolError.textContent = errorCount;
+ DOM.poolError.className = `stat-value ${errorCount > 0 ? 'red' : 'green'}`;
+ }
+ if (DOM.poolThreshold) DOM.poolThreshold.textContent = threshold;
+ if (DOM.poolPercent) {
+ DOM.poolPercent.textContent = fillPct + '%';
+ DOM.poolPercent.className = `stat-value ${fillPct >= 100 ? 'green' : fillPct >= 80 ? 'yellow' : 'red'}`;
+ }
+
+ updateHeaderCpa({ candidates, threshold, fillPct, errorCount });
+ } catch { }
+}
+
+async function triggerMaintenance() {
+ DOM.poolMaintainBtn.disabled = true;
+ DOM.poolMaintainBtn.textContent = '维护中...';
+ DOM.poolMaintainStatus.textContent = '正在探测并清理无效账号...';
+ try {
+ const res = await fetch('/api/pool/maintain', { method: 'POST' });
+ const data = await res.json();
+ if (res.ok) {
+ const msg = `维护完成: 无效 ${data.invalid_count || 0}, 已删除 ${data.deleted_ok || 0}, 失败 ${data.deleted_fail || 0}`;
+ DOM.poolMaintainStatus.textContent = msg;
+ showToast(msg, 'success');
+ pollPoolStatus();
+ } else {
+ DOM.poolMaintainStatus.textContent = data.detail || '维护失败';
+ showToast(data.detail || '维护失败', 'error');
+ }
+ } catch (e) {
+ DOM.poolMaintainStatus.textContent = '请求失败: ' + e.message;
+ showToast('维护请求失败', 'error');
+ } finally {
+ DOM.poolMaintainBtn.disabled = false;
+ DOM.poolMaintainBtn.textContent = '维护';
+ }
+}
+
+// ==========================================
+// Sub2Api 池状态轮询
+// ==========================================
+async function pollSub2ApiPoolStatus() {
+ try {
+ const res = await fetch('/api/sub2api/pool/status');
+ const data = await res.json();
+
+ if (data.configured && data.error) {
+ if (DOM.sub2apiPoolMaintainStatus) DOM.sub2apiPoolMaintainStatus.textContent = 'Sub2Api 状态获取失败: ' + data.error;
+ updateHeaderSub2Api(null);
+ return;
+ }
+
+ if (!data.configured) {
+ if (DOM.sub2apiPoolTotal) DOM.sub2apiPoolTotal.textContent = '--';
+ if (DOM.sub2apiPoolNormal) DOM.sub2apiPoolNormal.textContent = '--';
+ if (DOM.sub2apiPoolError) DOM.sub2apiPoolError.textContent = '--';
+ if (DOM.sub2apiPoolThreshold) DOM.sub2apiPoolThreshold.textContent = '--';
+ if (DOM.sub2apiPoolPercent) DOM.sub2apiPoolPercent.textContent = '--';
+ updateHeaderSub2Api(null);
+ return;
+ }
+
+ const normal = data.candidates || 0;
+ const error = data.error_count || 0;
+ const total = data.total || 0;
+ const threshold = data.threshold || 0;
+ // 充足率: 正常账号 / 目标阈值
+ const fillPct = threshold > 0 ? Math.round(normal / threshold * 100) : 100;
+ // 健康率: 正常账号 / 总账号 (无异常就是 100%)
+ const healthPct = total > 0 ? Math.round(normal / total * 100) : 100;
+
+ if (DOM.sub2apiPoolTotal) DOM.sub2apiPoolTotal.textContent = total;
+ if (DOM.sub2apiPoolNormal) DOM.sub2apiPoolNormal.textContent = normal;
+ if (DOM.sub2apiPoolError) {
+ DOM.sub2apiPoolError.textContent = error;
+ DOM.sub2apiPoolError.className = `stat-value ${error > 0 ? 'red' : 'green'}`;
+ }
+ if (DOM.sub2apiPoolThreshold) DOM.sub2apiPoolThreshold.textContent = threshold;
+ if (DOM.sub2apiPoolPercent) {
+ DOM.sub2apiPoolPercent.textContent = fillPct + '%';
+ DOM.sub2apiPoolPercent.className = `stat-value ${fillPct >= 100 ? 'green' : fillPct >= 80 ? 'yellow' : 'red'}`;
+ }
+
+ updateHeaderSub2Api({ normal, threshold, fillPct, error });
+ } catch { }
+}
+
+function updateHeaderSub2Api(data) {
+ if (!data) {
+ if (DOM.headerSub2apiLabel) DOM.headerSub2apiLabel.textContent = '-- / --';
+ if (DOM.headerSub2apiDelta) DOM.headerSub2apiDelta.textContent = '--';
+ if (DOM.headerSub2apiBar) DOM.headerSub2apiBar.style.width = '0%';
+ setHeaderChipStatus(DOM.headerSub2apiChip, 'idle');
+ if (DOM.headerSub2apiBar) DOM.headerSub2apiBar.className = 'pool-chip-fill';
+ if (DOM.headerSub2apiDelta) DOM.headerSub2apiDelta.className = 'pool-chip-delta';
+ return;
+ }
+ const { normal, threshold, fillPct, error: errorCount } = data;
+ const state = _headerPoolState(fillPct, errorCount);
+ if (DOM.headerSub2apiLabel) DOM.headerSub2apiLabel.textContent = `${normal} / ${threshold}`;
+ if (DOM.headerSub2apiDelta) DOM.headerSub2apiDelta.textContent = _headerPoolDelta(fillPct);
+ if (DOM.headerSub2apiBar) {
+ DOM.headerSub2apiBar.style.width = Math.min(100, fillPct) + '%';
+ DOM.headerSub2apiBar.className = `pool-chip-fill ${state}`;
+ }
+ setHeaderChipStatus(DOM.headerSub2apiChip, state);
+ if (DOM.headerSub2apiDelta) DOM.headerSub2apiDelta.className = `pool-chip-delta ${state}`;
+}
+
+function updateHeaderCpa(data) {
+ if (!data) {
+ if (DOM.headerCpaLabel) DOM.headerCpaLabel.textContent = '-- / --';
+ if (DOM.headerCpaDelta) DOM.headerCpaDelta.textContent = '--';
+ if (DOM.headerCpaBar) DOM.headerCpaBar.style.width = '0%';
+ setHeaderChipStatus(DOM.headerCpaChip, 'idle');
+ if (DOM.headerCpaBar) DOM.headerCpaBar.className = 'pool-chip-fill';
+ if (DOM.headerCpaDelta) DOM.headerCpaDelta.className = 'pool-chip-delta';
+ return;
+ }
+ const { candidates, threshold, fillPct, errorCount } = data;
+ const state = _headerPoolState(fillPct, errorCount);
+ if (DOM.headerCpaLabel) DOM.headerCpaLabel.textContent = `${candidates} / ${threshold}`;
+ if (DOM.headerCpaDelta) DOM.headerCpaDelta.textContent = _headerPoolDelta(fillPct);
+ if (DOM.headerCpaBar) {
+ DOM.headerCpaBar.style.width = Math.min(100, fillPct) + '%';
+ DOM.headerCpaBar.className = `pool-chip-fill ${state}`;
+ }
+ setHeaderChipStatus(DOM.headerCpaChip, state);
+ if (DOM.headerCpaDelta) DOM.headerCpaDelta.className = `pool-chip-delta ${state}`;
+}
+
+function setHeaderChipStatus(chip, state) {
+ if (!chip) return;
+ chip.classList.remove('status-idle', 'status-warn', 'status-danger', 'status-ok', 'status-over');
+ chip.classList.add(`status-${state}`);
+}
+
+function _headerPoolState(fillPct, errorCount) {
+ if (errorCount > 0) return 'danger';
+ if (fillPct > 110) return 'over';
+ if (fillPct >= 100) return 'ok';
+ if (fillPct >= 80) return 'warn';
+ return 'danger';
+}
+
+function _headerPoolDelta(fillPct) {
+ if (!Number.isFinite(fillPct)) return '--';
+ const delta = Math.round(fillPct - 100);
+ if (delta === 0) return '0%';
+ return `${delta > 0 ? '+' : ''}${delta}%`;
+}
+
+async function triggerSub2ApiMaintenance() {
+ const actionsText = describeSub2ApiMaintainActions();
+ DOM.sub2apiPoolMaintainBtn.disabled = true;
+ DOM.sub2apiPoolMaintainBtn.textContent = '维护中...';
+ DOM.sub2apiPoolMaintainStatus.textContent = `正在维护(${actionsText})...`;
+ try {
+ const res = await fetch('/api/sub2api/pool/maintain', { method: 'POST' });
+ const data = await res.json();
+ if (res.ok) {
+ const sec = Math.max(0, Number(data.duration_ms || 0) / 1000).toFixed(2);
+ const msg = `维护完成(${actionsText}): 异常 ${data.error_count || 0}, 刷新恢复 ${data.refreshed || 0}, 重复组 ${data.duplicate_groups || 0}, 删除 ${data.deleted_ok || 0}, 失败 ${data.deleted_fail || 0}, ${sec}s`;
+ DOM.sub2apiPoolMaintainStatus.textContent = msg;
+ showToast(msg, 'success');
+ pollSub2ApiPoolStatus();
+ loadSub2ApiAccounts({ silent: true });
+ } else {
+ DOM.sub2apiPoolMaintainStatus.textContent = data.detail || '维护失败';
+ showToast(data.detail || '维护失败', 'error');
+ }
+ } catch (e) {
+ DOM.sub2apiPoolMaintainStatus.textContent = '请求失败: ' + e.message;
+ showToast('Sub2Api 维护请求失败', 'error');
+ } finally {
+ DOM.sub2apiPoolMaintainBtn.disabled = false;
+ DOM.sub2apiPoolMaintainBtn.textContent = '维护';
+ }
+}
+
+async function testProxyPoolFetch() {
+ if (!DOM.proxyPoolTestBtn) return;
+ DOM.proxyPoolTestBtn.disabled = true;
+ const oldText = DOM.proxyPoolTestBtn.textContent;
+ if (DOM.proxyPoolStatus) DOM.proxyPoolStatus.textContent = '正在测试代理池取号...';
+ DOM.proxyPoolTestBtn.textContent = '测试取号中...';
+ try {
+ const payload = {
+ enabled: DOM.proxyPoolEnabled ? DOM.proxyPoolEnabled.checked : true,
+ api_url: DOM.proxyPoolApiUrl ? DOM.proxyPoolApiUrl.value.trim() : 'https://zenproxy.top/api/fetch',
+ auth_mode: DOM.proxyPoolAuthMode ? DOM.proxyPoolAuthMode.value : 'query',
+ api_key: DOM.proxyPoolApiKey ? DOM.proxyPoolApiKey.value.trim() : '',
+ count: DOM.proxyPoolCount ? (parseInt(DOM.proxyPoolCount.value, 10) || 1) : 1,
+ country: DOM.proxyPoolCountry ? DOM.proxyPoolCountry.value.trim().toUpperCase() : 'US',
+ };
+ const res = await fetch('/api/proxy-pool/test', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json();
+ if (!res.ok || !data.ok) {
+ const msg = data.error || data.detail || '代理池取号失败';
+ if (DOM.proxyPoolStatus) DOM.proxyPoolStatus.textContent = msg;
+ showToast(msg, 'error');
+ return;
+ }
+ const locText = data.loc ? ` loc=${data.loc}` : '';
+ const supportText = data.supported === null || data.supported === undefined
+ ? ''
+ : (data.supported ? ' 可用' : ' 不可用(CN/HK)');
+ const traceWarn = data.trace_error ? `;trace失败: ${data.trace_error}` : '';
+ const msg = `取号成功: ${data.proxy}${locText}${supportText}${traceWarn}`;
+ if (DOM.proxyPoolStatus) DOM.proxyPoolStatus.textContent = msg;
+ showToast('代理池取号成功', 'success');
+ } catch (e) {
+ const msg = '测试请求失败: ' + e.message;
+ if (DOM.proxyPoolStatus) DOM.proxyPoolStatus.textContent = msg;
+ showToast(msg, 'error');
+ } finally {
+ DOM.proxyPoolTestBtn.disabled = false;
+ if (DOM.syncStatus) DOM.syncStatus.textContent = '';
+ DOM.proxyPoolTestBtn.textContent = oldText || '测试代理池取号';
+ }
+}
+
+async function testSub2ApiPoolConnection() {
+ DOM.sub2apiTestPoolBtn.disabled = true;
+ DOM.syncStatus.textContent = '测试连接中...';
+ try {
+ const res = await fetch('/api/sub2api/pool/check', { method: 'POST' });
+ const data = await res.json();
+ if (data.ok) {
+ DOM.syncStatus.textContent = data.message || '连接成功';
+ showToast('Sub2Api 池连接成功', 'success');
+ } else {
+ DOM.syncStatus.textContent = data.message || data.detail || '连接失败';
+ showToast('Sub2Api 池连接失败', 'error');
+ }
+ } catch (e) {
+ DOM.syncStatus.textContent = '请求失败: ' + e.message;
+ } finally {
+ DOM.sub2apiTestPoolBtn.disabled = false;
+ }
+}
+
+// ==========================================
+// 邮箱配置(多选)
+// ==========================================
+
+function initMailCheckboxes() {
+ document.querySelectorAll('.mail-provider-check').forEach(cb => {
+ cb.setAttribute('aria-expanded', cb.checked);
+ cb.addEventListener('change', () => {
+ const item = cb.closest('.provider-item');
+ const config = item.querySelector('.provider-config');
+ if (config) config.style.display = cb.checked ? 'block' : 'none';
+ cb.setAttribute('aria-expanded', cb.checked);
+ });
+ });
+}
+
+async function loadMailConfig() {
+ try {
+ const res = await fetch('/api/mail/config');
+ const data = await res.json();
+ const providers = data.mail_providers || [data.mail_provider || 'mailtm'];
+ const configs = data.mail_provider_configs || {};
+ const strategy = data.mail_strategy || 'round_robin';
+
+ // 设置 checkboxes
+ document.querySelectorAll('.mail-provider-check').forEach(cb => {
+ const name = cb.value;
+ cb.checked = providers.includes(name);
+ const item = cb.closest('.provider-item');
+ const configDiv = item.querySelector('.provider-config');
+ if (configDiv) configDiv.style.display = cb.checked ? 'block' : 'none';
+
+ // 填充 per-provider 配置
+ const pcfg = configs[name] || {};
+ item.querySelectorAll('[data-key]').forEach(input => {
+ const key = input.dataset.key;
+ const previewKey = key + '_preview';
+ if (pcfg[key]) input.value = pcfg[key];
+ else if (pcfg[previewKey]) input.placeholder = pcfg[previewKey];
+ });
+ });
+
+ // 兼容旧格式
+ if (!data.mail_providers && data.mail_config) {
+ const mc = data.mail_config;
+ const activeProvider = data.mail_provider || 'mailtm';
+ const item = document.querySelector(`.provider-item[data-provider="${activeProvider}"]`);
+ if (item) {
+ const apiBaseInput = item.querySelector('[data-key="api_base"]');
+ if (apiBaseInput && mc.api_base) apiBaseInput.value = mc.api_base;
+ }
+ }
+
+ if (DOM.mailStrategySelect) DOM.mailStrategySelect.value = strategy;
+ } catch { }
+}
+
+async function saveMailConfig() {
+ const checkedProviders = [];
+ const providerConfigs = {};
+
+ document.querySelectorAll('.mail-provider-check').forEach(cb => {
+ const name = cb.value;
+ if (cb.checked) {
+ checkedProviders.push(name);
+ const item = cb.closest('.provider-item');
+ const cfg = {};
+ item.querySelectorAll('[data-key]').forEach(input => {
+ cfg[input.dataset.key] = input.value.trim();
+ });
+ providerConfigs[name] = cfg;
+ }
+ });
+
+ if (checkedProviders.length === 0) {
+ showToast('请至少选择一个邮箱提供商', 'error');
+ return false;
+ }
+
+ const strategy = DOM.mailStrategySelect ? DOM.mailStrategySelect.value : 'round_robin';
+ DOM.mailSaveBtn.disabled = true;
+ try {
+ const res = await fetch('/api/mail/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ mail_provider: checkedProviders[0],
+ mail_config: providerConfigs[checkedProviders[0]] || {},
+ mail_providers: checkedProviders,
+ mail_provider_configs: providerConfigs,
+ mail_strategy: strategy,
+ }),
+ });
+ if (res.ok) {
+ showToast('邮箱配置已保存', 'success');
+ DOM.mailStatus.textContent = '配置已保存';
+ return true;
+ } else {
+ const data = await res.json();
+ DOM.mailStatus.textContent = data.detail || '保存失败';
+ showToast(DOM.mailStatus.textContent, 'error');
+ return false;
+ }
+ } catch (e) {
+ DOM.mailStatus.textContent = '请求失败: ' + e.message;
+ showToast(DOM.mailStatus.textContent, 'error');
+ return false;
+ } finally {
+ DOM.mailSaveBtn.disabled = false;
+ }
+}
+
+async function testMailConnection() {
+ DOM.mailTestBtn.disabled = true;
+ DOM.mailStatus.textContent = '测试中...';
+ try {
+ const saved = await saveMailConfig();
+ if (!saved) return;
+ const res = await fetch('/api/mail/test', { method: 'POST' });
+ const data = await res.json();
+ if (data.results) {
+ const msgs = data.results.map(r => `${r.provider}: ${r.ok ? 'OK' : r.message}`);
+ DOM.mailStatus.textContent = msgs.join(' | ');
+ } else {
+ DOM.mailStatus.textContent = data.message || (data.ok ? '连接成功' : '连接失败');
+ }
+ showToast(data.ok ? '邮箱测试通过' : '邮箱测试失败', data.ok ? 'success' : 'error');
+ } catch (e) {
+ DOM.mailStatus.textContent = '请求失败: ' + e.message;
+ } finally {
+ DOM.mailTestBtn.disabled = false;
+ }
+}
+
+// ==========================================
+// Toast 通知 — 带图标和退出动画
+// ==========================================
+const TOAST_ICONS = {
+ success: '✓',
+ error: '✗',
+ info: 'ℹ',
+};
+
+const THEME_STORAGE_KEY = 'oai_registrar_theme_v1';
+
+function initThemeSwitch() {
+ const btn = DOM.themeToggleBtn;
+ if (!btn) return;
+
+ let saved = 'dark';
+ try {
+ const value = localStorage.getItem(THEME_STORAGE_KEY);
+ if (value === 'light' || value === 'dark') saved = value;
+ } catch { }
+
+ applyTheme(saved);
+
+ btn.addEventListener('click', () => {
+ const isLight = document.body.classList.contains('theme-light');
+ const nextTheme = isLight ? 'dark' : 'light';
+ applyTheme(nextTheme);
+ try { localStorage.setItem(THEME_STORAGE_KEY, nextTheme); } catch { }
+ });
+}
+
+function applyTheme(theme) {
+ const isLight = theme === 'light';
+ document.body.classList.toggle('theme-light', isLight);
+ updateThemeToggleLabel(isLight);
+}
+
+function updateThemeToggleLabel(isLight) {
+ const btn = DOM.themeToggleBtn;
+ if (!btn) return;
+ const currentLabel = isLight ? '\u660e\u4eae' : '\u9ed1\u6697';
+ const nextLabel = isLight ? '\u9ed1\u6697' : '\u660e\u4eae';
+ const toggleLabel = btn.querySelector('.theme-toggle-label');
+ if (toggleLabel) toggleLabel.textContent = currentLabel;
+ btn.setAttribute('aria-label', `\u5207\u6362\u5230${nextLabel}\u4e3b\u9898`);
+ btn.setAttribute('title', `\u5207\u6362\u5230${nextLabel}\u4e3b\u9898`);
+}
+
+function showToast(msg, type = 'info') {
+ const container = $('toastContainer');
+ const toast = document.createElement('div');
+ toast.className = `toast ${type}`;
+ const iconHtml = TOAST_ICONS[type] || TOAST_ICONS.info;
+ toast.innerHTML = `${iconHtml}${escapeHtml(msg)}`;
+ container.appendChild(toast);
+ setTimeout(() => {
+ toast.style.animation = 'toast-out .25s var(--ease-spring) forwards';
+ toast.addEventListener('animationend', () => toast.remove());
+ }, 3200);
+}
+
+// ==========================================
+// 工具函数
+// ==========================================
+function escapeHtml(str) {
+ return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
+}
+
+function cssEscape(str) {
+ return str.replace(/[^a-zA-Z0-9_-]/g, '_');
+}
+
+// ==========================================
+// 拖拽调整栏宽度 + localStorage 持久化
+// ==========================================
+(function initResizable() {
+ const STORAGE_KEY = 'oai_registrar_layout_v3';
+ const shell = document.querySelector('.app-shell');
+ const resizeLeft = document.getElementById('resizeLeft');
+ const resizeRight = document.getElementById('resizeRight');
+ if (!shell) return;
+
+ function getTrackPx(index) {
+ const tracks = getComputedStyle(shell).gridTemplateColumns.match(/[\d.]+px/g) || [];
+ const val = tracks[index] ? parseFloat(tracks[index]) : NaN;
+ return Number.isFinite(val) ? val : NaN;
+ }
+
+ function loadLayout() {
+ try {
+ const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ if (!saved) return;
+ const maxW = shell.getBoundingClientRect().width || window.innerWidth;
+ if (saved.left && saved.left >= 220 && saved.left <= maxW * 0.4) {
+ shell.style.setProperty('--col-left', saved.left + 'px');
+ }
+ if (saved.right && saved.right >= 260 && saved.right <= maxW * 0.4) {
+ shell.style.setProperty('--col-right', saved.right + 'px');
+ }
+ } catch { }
+ }
+
+ function saveLayout() {
+ const left = getTrackPx(0);
+ const right = getTrackPx(4);
+ const data = {};
+ if (Number.isFinite(left) && left > 0) data.left = left;
+ if (Number.isFinite(right) && right > 0) data.right = right;
+ if (Object.keys(data).length) {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch { }
+ }
+ }
+
+ function initHandle(handle, prop, minW, getStart) {
+ if (!handle) return;
+ handle.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ document.body.classList.add('resizing');
+ handle.classList.add('active');
+ const startX = e.clientX;
+ const startVal = getStart();
+ const totalW = shell.getBoundingClientRect().width;
+
+ const onMove = (ev) => {
+ const dx = ev.clientX - startX;
+ const delta = prop === '--col-left' ? dx : -dx;
+ shell.style.setProperty(prop, Math.max(minW, Math.min(startVal + delta, totalW * 0.4)) + 'px');
+ };
+ const onUp = () => {
+ document.body.classList.remove('resizing');
+ handle.classList.remove('active');
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ saveLayout();
+ };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ });
+ }
+
+ initHandle(resizeLeft, '--col-left', 220, () => getTrackPx(0) || 280);
+ initHandle(resizeRight, '--col-right', 260, () => getTrackPx(4) || 340);
+
+ loadLayout();
+})();
diff --git a/openai_pool_orchestrator/static/index.html b/openai_pool_orchestrator/static/index.html
new file mode 100755
index 0000000..9dcb401
--- /dev/null
+++ b/openai_pool_orchestrator/static/index.html
@@ -0,0 +1,694 @@
+
+
+
+
+
+
+ OpenAI Pool Orchestrator
+
+
+
+
+
+
+
+
+
+
+
Pool Orchestrator
+
v5.2.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 全局上传策略
+ ▶
+
+
+
+
+
+
+ 串行模式更保守;并行模式会让单账号并发上传到两个平台。当前任务运行中切换策略,将在下轮任务生效。
+
+
+
+
+
+
+
+
+
+
+
+
+ 请求代理池配置
+ ▶
+
+
+
+ 在每次请求前动态取号。默认接口将附带 api_key、count、country 参数。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CPA 平台配置
+ ▶
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sub2Api 平台配置
+ ▶
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 邮箱提供商
+ ▶
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/openai_pool_orchestrator/static/style.css b/openai_pool_orchestrator/static/style.css
new file mode 100755
index 0000000..3e7b95b
--- /dev/null
+++ b/openai_pool_orchestrator/static/style.css
@@ -0,0 +1,2226 @@
+/* ==========================================
+ OpenAI Pool Orchestrator — iOS Flat Design v2.1
+ Modern · Flat · iOS Switch Style
+ ========================================== */
+
+@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
+
+/* ---------- Design Tokens ---------- */
+:root {
+ /* Surfaces — layered depth */
+ --bg-base: #000000;
+ --bg-surface: #1c1c1e;
+ --bg-card: #2c2c2e;
+ --bg-elevated: #3a3a3c;
+ --bg-hover: rgba(255,255,255,.06);
+ --bg-inset: rgba(0,0,0,.25);
+
+ /* Borders — subtle definition */
+ --border: rgba(255,255,255,.08);
+ --border-card: rgba(255,255,255,.06);
+ --border-focused: rgba(10,132,255,.5);
+ --separator: rgba(255,255,255,.05);
+
+ /* iOS System Colors */
+ --accent-blue: #0a84ff;
+ --accent-blue-dim: rgba(10,132,255,.15);
+ --accent-green: #30d158;
+ --accent-green-dim: rgba(48,209,88,.12);
+ --accent-red: #ff453a;
+ --accent-red-dim: rgba(255,69,58,.12);
+ --accent-yellow: #ffd60a;
+ --accent-orange: #ff9f0a;
+ --accent-orange-dim: rgba(255,159,10,.12);
+ --accent-purple: #bf5af2;
+ --accent-teal: #64d2ff;
+ --accent-teal-dim: rgba(100,210,255,.12);
+
+ /* Text */
+ --text-primary: rgba(255,255,255,.92);
+ --text-secondary: rgba(255,255,255,.55);
+ --text-muted: rgba(255,255,255,.30);
+
+ /* Radius — iOS rounded corners */
+ --radius-xs: 6px;
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --radius-xl: 20px;
+ --radius-pill: 999px;
+
+ /* Shadows */
+ --shadow-card: 0 1px 3px rgba(0,0,0,.3), 0 0 0 1px var(--border-card);
+ --shadow-card-hover: 0 4px 16px rgba(0,0,0,.4), 0 0 0 1px rgba(255,255,255,.1);
+ --shadow-elevated: 0 8px 30px rgba(0,0,0,.5);
+ --shadow-button: 0 1px 2px rgba(0,0,0,.3);
+ --shadow-glow-blue: 0 0 20px rgba(10,132,255,.15);
+ --shadow-glow-green: 0 0 20px rgba(48,209,88,.15);
+ --shadow-glow-red: 0 0 20px rgba(255,69,58,.15);
+
+ /* Transitions */
+ --ease-spring: cubic-bezier(.4, 0, .2, 1);
+ --ease-bounce: cubic-bezier(.34, 1.56, .64, 1);
+ --ease-out: cubic-bezier(0, 0, .2, 1);
+ --duration-fast: .15s;
+ --duration-normal: .25s;
+ --duration-slow: .4s;
+ --watermark-svg: url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27420%27%20height%3D%27240%27%20viewBox%3D%270%200%20420%20240%27%3E%0A%20%20%3Cdefs%3E%0A%20%20%20%20%3CclipPath%20id%3D%27ld_clip_wm%27%3E%0A%20%20%20%20%20%20%3Ccircle%20cx%3D%2760%27%20cy%3D%2760%27%20r%3D%2747%27%3E%3C%2Fcircle%3E%0A%20%20%20%20%3C%2FclipPath%3E%0A%20%20%3C%2Fdefs%3E%0A%20%20%3Cg%20transform%3D%27rotate%28-22%20210%20120%29%27%3E%0A%20%20%20%20%3Cg%20transform%3D%27translate%2878%2078%29%20scale%280.37%29%27%20opacity%3D%270.20%27%3E%0A%20%20%20%20%20%20%3Ccircle%20fill%3D%27%23f0f0f0%27%20cx%3D%2760%27%20cy%3D%2760%27%20r%3D%2750%27%3E%3C%2Fcircle%3E%0A%20%20%20%20%20%20%3Crect%20fill%3D%27%231c1c1e%27%20clip-path%3D%27url%28%23ld_clip_wm%29%27%20x%3D%2710%27%20y%3D%2710%27%20width%3D%27100%27%20height%3D%2730%27%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%3Crect%20fill%3D%27%23f0f0f0%27%20clip-path%3D%27url%28%23ld_clip_wm%29%27%20x%3D%2710%27%20y%3D%2740%27%20width%3D%27100%27%20height%3D%2740%27%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%3Crect%20fill%3D%27%23ffb003%27%20clip-path%3D%27url%28%23ld_clip_wm%29%27%20x%3D%2710%27%20y%3D%2780%27%20width%3D%27100%27%20height%3D%2730%27%3E%3C%2Frect%3E%0A%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3Ctext%20x%3D%27136%27%20y%3D%27108%27%20fill%3D%27rgba%28255%2C255%2C255%2C0.10%29%27%20font-family%3D%27Arial%2C%20Microsoft%20YaHei%2C%20sans-serif%27%20font-size%3D%2724%27%20font-weight%3D%27700%27%20letter-spacing%3D%271.8%27%3ELINUX%20DO%20%E7%A4%BE%E5%8C%BA%3C%2Ftext%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E");
+}
+
+body.theme-light {
+ --bg-base: #f2f2f3;
+ --bg-surface: #ffffff;
+ --bg-card: #f7f7f8;
+ --bg-elevated: #ffffff;
+ --bg-hover: rgba(0,0,0,.04);
+ --bg-inset: rgba(0,0,0,.04);
+
+ --border: rgba(0,0,0,.12);
+ --border-card: rgba(0,0,0,.08);
+ --border-focused: rgba(0,0,0,.34);
+ --separator: rgba(0,0,0,.08);
+
+ --accent-blue: #111111;
+ --accent-blue-dim: rgba(0,0,0,.10);
+ --accent-green: #0f9f57;
+ --accent-green-dim: rgba(15,159,87,.14);
+ --accent-red: #d73a2f;
+ --accent-red-dim: rgba(215,58,47,.12);
+ --accent-yellow: #b88a00;
+ --accent-orange: #c27600;
+ --accent-orange-dim: rgba(194,118,0,.12);
+ --accent-purple: #444444;
+ --accent-teal: #353535;
+ --accent-teal-dim: rgba(53,53,53,.12);
+
+ --text-primary: rgba(17,17,17,.92);
+ --text-secondary: rgba(17,17,17,.64);
+ --text-muted: rgba(17,17,17,.42);
+
+ --shadow-card: 0 1px 2px rgba(0,0,0,.06), 0 0 0 1px var(--border-card);
+ --shadow-card-hover: 0 4px 14px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.1);
+ --shadow-elevated: 0 12px 34px rgba(0,0,0,.10);
+ --shadow-button: 0 1px 2px rgba(0,0,0,.12);
+ --shadow-glow-blue: none;
+ --shadow-glow-green: none;
+ --shadow-glow-red: none;
+ --watermark-svg: url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27420%27%20height%3D%27240%27%20viewBox%3D%270%200%20420%20240%27%3E%0A%20%20%3Cdefs%3E%0A%20%20%20%20%3CclipPath%20id%3D%27ld_clip_wm%27%3E%0A%20%20%20%20%20%20%3Ccircle%20cx%3D%2760%27%20cy%3D%2760%27%20r%3D%2747%27%3E%3C%2Fcircle%3E%0A%20%20%20%20%3C%2FclipPath%3E%0A%20%20%3C%2Fdefs%3E%0A%20%20%3Cg%20transform%3D%27rotate%28-22%20210%20120%29%27%3E%0A%20%20%20%20%3Cg%20transform%3D%27translate%2878%2078%29%20scale%280.37%29%27%20opacity%3D%270.16%27%3E%0A%20%20%20%20%20%20%3Ccircle%20fill%3D%27%23f0f0f0%27%20cx%3D%2760%27%20cy%3D%2760%27%20r%3D%2750%27%3E%3C%2Fcircle%3E%0A%20%20%20%20%20%20%3Crect%20fill%3D%27%231c1c1e%27%20clip-path%3D%27url%28%23ld_clip_wm%29%27%20x%3D%2710%27%20y%3D%2710%27%20width%3D%27100%27%20height%3D%2730%27%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%3Crect%20fill%3D%27%23f0f0f0%27%20clip-path%3D%27url%28%23ld_clip_wm%29%27%20x%3D%2710%27%20y%3D%2740%27%20width%3D%27100%27%20height%3D%2740%27%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%3Crect%20fill%3D%27%23ffb003%27%20clip-path%3D%27url%28%23ld_clip_wm%29%27%20x%3D%2710%27%20y%3D%2780%27%20width%3D%27100%27%20height%3D%2730%27%3E%3C%2Frect%3E%0A%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3Ctext%20x%3D%27136%27%20y%3D%27108%27%20fill%3D%27rgba%280%2C0%2C0%2C0.10%29%27%20font-family%3D%27Arial%2C%20Microsoft%20YaHei%2C%20sans-serif%27%20font-size%3D%2724%27%20font-weight%3D%27700%27%20letter-spacing%3D%271.8%27%3ELINUX%20DO%20%E7%A4%BE%E5%8C%BA%3C%2Ftext%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E");
+}
+
+/* ---------- Reset ---------- */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html, body { height: 100%; }
+
+body {
+ font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
+ font-size: 14px;
+ background: var(--bg-base);
+ color: var(--text-primary);
+ line-height: 1.5;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ z-index: 999;
+ pointer-events: none;
+ background-image: var(--watermark-svg);
+ background-repeat: repeat;
+ background-size: 420px 240px;
+}
+
+/* ---------- Scrollbar — Thin & Subtle ---------- */
+* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.1) transparent; }
+::-webkit-scrollbar { width: 4px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 9px; }
+::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.18); }
+
+/* ==========================================
+ HEADER — Glassmorphism Bar
+ ========================================== */
+header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 20px;
+ min-height: 52px;
+ background: rgba(28,28,30,.82);
+ backdrop-filter: saturate(180%) blur(20px);
+ -webkit-backdrop-filter: saturate(180%) blur(20px);
+ border-bottom: 1px solid var(--border);
+ position: relative;
+ z-index: 10;
+ flex-shrink: 0;
+}
+
+.header-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.header-brand .logo {
+ width: 30px;
+ height: 30px;
+ background: linear-gradient(135deg, var(--accent-blue) 0%, #409cff 100%);
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ box-shadow: 0 2px 8px rgba(10,132,255,.35);
+}
+
+.header-brand h1 {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--text-primary);
+ letter-spacing: -.3px;
+}
+
+.header-brand .version {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--accent-blue);
+ background: var(--accent-blue-dim);
+ padding: 2px 8px;
+ border-radius: var(--radius-pill);
+ letter-spacing: .3px;
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-shrink: 0;
+ min-width: 0;
+}
+
+.theme-toggle-btn {
+ height: 34px;
+ min-width: 68px;
+ padding: 0 10px;
+ border: 1px solid var(--border-card);
+ border-radius: var(--radius-pill);
+ background: var(--bg-card);
+ color: var(--text-primary);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: all var(--duration-fast) var(--ease-spring);
+}
+
+.theme-toggle-btn:hover {
+ border-color: rgba(255,255,255,.14);
+ background: var(--bg-elevated);
+}
+
+.theme-toggle-btn:active {
+ transform: scale(.98);
+}
+
+.theme-toggle-icon {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 2px solid currentColor;
+ position: relative;
+}
+
+.theme-toggle-icon::after {
+ content: '';
+ position: absolute;
+ inset: 2px;
+ border-radius: 50%;
+ background: currentColor;
+ opacity: .25;
+}
+
+.theme-toggle-label {
+ letter-spacing: .3px;
+}
+
+/* ---------- Status Badge ---------- */
+.status-badge {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 14px;
+ border-radius: var(--radius-pill);
+ font-size: 12px;
+ font-weight: 600;
+ background: var(--bg-card);
+ border: 1px solid var(--border-card);
+ transition: all .3s var(--ease-spring);
+}
+
+.status-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--text-muted);
+ transition: all .3s;
+}
+
+.status-badge.running {
+ background: var(--accent-green-dim);
+ border-color: rgba(48,209,88,.2);
+}
+.status-badge.running .status-dot {
+ background: var(--accent-green);
+ box-shadow: 0 0 8px var(--accent-green);
+ animation: pulse-dot 1.8s infinite;
+}
+
+.status-badge.stopping {
+ background: var(--accent-orange-dim);
+ border-color: rgba(255,159,10,.2);
+}
+.status-badge.stopping .status-dot {
+ background: var(--accent-orange);
+}
+
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: .5; transform: scale(1.4); }
+}
+
+/* ---------- Pool Chips — Mini Dashboard ---------- */
+.pool-chip {
+ height: 46px;
+ min-width: 148px;
+ border-radius: var(--radius-md);
+ background: var(--bg-card);
+ border: 1px solid var(--border-card);
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: 14px 17px 3px;
+ grid-template-areas: "name delta" "value delta" "track track";
+ align-items: end;
+ gap: 2px 8px;
+ padding: 6px 10px 5px;
+ overflow: hidden;
+ white-space: nowrap;
+ transition: all .25s var(--ease-spring);
+}
+.pool-chip:hover { border-color: rgba(255,255,255,.12); }
+
+.pool-chip.interactive-chip {
+ cursor: pointer;
+ user-select: none;
+}
+
+.pool-chip.interactive-chip.active-view {
+ border-color: rgba(10,132,255,.32);
+ box-shadow: 0 0 0 1px rgba(10,132,255,.22), 0 8px 18px rgba(10,132,255,.14);
+}
+
+.pool-chip.interactive-chip:focus-visible {
+ outline: 2px solid var(--accent-blue);
+ outline-offset: 2px;
+}
+
+.pool-chip-name { grid-area: name; font-size: 10px; font-weight: 600; color: var(--text-muted); letter-spacing: .4px; text-transform: uppercase; line-height: 1; }
+.pool-chip-value { grid-area: value; font-size: 13px; font-weight: 700; color: var(--text-primary); white-space: nowrap; font-family: 'JetBrains Mono', monospace; line-height: 1; }
+.pool-chip-delta { grid-area: delta; font-size: 11px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: var(--radius-sm); background: rgba(255,255,255,.06); color: var(--text-secondary); align-self: center; }
+.pool-chip-track { grid-area: track; height: 3px; border-radius: 9px; background: rgba(255,255,255,.06); overflow: hidden; }
+.pool-chip-fill { height: 100%; border-radius: 9px; background: var(--accent-green); transition: width .4s var(--ease-spring), background .2s; }
+.pool-chip-fill.warn { background: var(--accent-orange); }
+.pool-chip-fill.danger { background: var(--accent-red); }
+.pool-chip-fill.ok { background: var(--accent-green); }
+.pool-chip-fill.over { background: var(--accent-teal); }
+.pool-chip.status-warn { background: rgba(255,159,10,.06); border-color: rgba(255,159,10,.15); }
+.pool-chip.status-danger { background: rgba(255,69,58,.06); border-color: rgba(255,69,58,.15); }
+.pool-chip.status-ok { background: rgba(48,209,88,.04); border-color: rgba(48,209,88,.12); }
+.pool-chip.status-over { background: rgba(100,210,255,.04); border-color: rgba(100,210,255,.12); }
+.pool-chip-delta.warn { color: var(--accent-orange); background: var(--accent-orange-dim); }
+.pool-chip-delta.danger { color: var(--accent-red); background: var(--accent-red-dim); }
+.pool-chip-delta.ok { color: var(--accent-green); background: var(--accent-green-dim); }
+.pool-chip-delta.over { color: var(--accent-teal); background: var(--accent-teal-dim); }
+
+/* ==========================================
+ PROGRESS BAR
+ ========================================== */
+.progress-bar {
+ height: 2px;
+ background: var(--bg-surface);
+ position: relative;
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent-blue), var(--accent-teal));
+ width: 0;
+ transition: width .5s var(--ease-spring);
+}
+
+.progress-fill.running {
+ animation: progress-slide 1.4s infinite linear;
+ width: 35%;
+}
+
+.progress-fill.stopping {
+ width: 100%;
+ background: linear-gradient(90deg, var(--accent-orange), var(--accent-red));
+ opacity: .78;
+}
+
+@keyframes progress-slide {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(380%); }
+}
+
+/* ==========================================
+ TAB NAVIGATION — True iOS Segmented Control
+ ========================================== */
+.tab-nav {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 12px;
+ background: transparent;
+ border-bottom: none;
+ flex-shrink: 0;
+}
+
+.header-tab-nav {
+ flex: 1;
+ min-width: 0;
+}
+
+.header-tab-nav .segmented-control {
+ max-width: 100%;
+}
+
+.segmented-control {
+ display: inline-flex;
+ align-items: center;
+ background: rgba(118,118,128,.24);
+ border-radius: 9px;
+ padding: 2px;
+ position: relative;
+ gap: 0;
+}
+
+.segment-indicator {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ height: calc(100% - 4px);
+ width: calc(50% - 2px);
+ background: var(--bg-elevated);
+ border-radius: 7px;
+ transition: transform var(--duration-normal) var(--ease-spring);
+ z-index: 0;
+ box-shadow: 0 1px 3px rgba(0,0,0,.2), 0 0 0 .5px rgba(0,0,0,.1);
+}
+
+.segment-indicator[data-active="1"] {
+ transform: translateX(calc(100% + 2px));
+}
+
+.tab-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 6px 24px;
+ border-radius: 7px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ transition: color var(--duration-fast) var(--ease-spring);
+ position: relative;
+ z-index: 1;
+ min-width: 120px;
+ user-select: none;
+}
+
+.tab-btn svg { opacity: .7; transition: opacity var(--duration-fast); }
+.tab-btn:hover { color: var(--text-primary); }
+.tab-btn:hover svg { opacity: .9; }
+.tab-btn.active { color: var(--text-primary); }
+.tab-btn.active svg { opacity: 1; }
+
+/* ==========================================
+ TAB PANELS — Fade Transition
+ ========================================== */
+.tab-panel {
+ display: none;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ animation: tab-fade-in .2s var(--ease-out);
+}
+.tab-panel.active { display: flex; flex-direction: column; }
+
+@keyframes tab-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* ==========================================
+ THREE-COLUMN LAYOUT
+ ========================================== */
+.app-shell {
+ display: grid;
+ grid-template-columns: var(--col-left, 280px) 4px 1fr 4px var(--col-right, 340px);
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* ---------- Resize Handle ---------- */
+.resize-handle {
+ width: 4px;
+ cursor: col-resize;
+ background: transparent;
+ transition: background var(--duration-fast);
+ position: relative;
+ z-index: 5;
+ flex-shrink: 0;
+}
+.resize-handle::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 2px;
+ height: 24px;
+ border-radius: 1px;
+ background: rgba(255,255,255,.08);
+ opacity: 0;
+ transition: opacity var(--duration-normal);
+}
+.resize-handle:hover::after, .resize-handle.active::after {
+ opacity: 1;
+ background: var(--accent-blue);
+}
+.resize-handle:hover, .resize-handle.active {
+ background: rgba(10,132,255,.2);
+}
+body.resizing { cursor: col-resize !important; user-select: none !important; -webkit-user-select: none !important; }
+body.resizing * { cursor: col-resize !important; }
+
+/* ==========================================
+ LEFT SIDEBAR
+ ========================================== */
+.sidebar {
+ background: var(--bg-surface);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-width: 0;
+}
+
+.panel-section {
+ padding: 12px 12px 10px;
+ border-bottom: 1px solid var(--separator);
+}
+.panel-section:last-child { border-bottom: none; }
+
+.section-title {
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: .6px;
+ margin-bottom: 8px;
+}
+
+/* ==========================================
+ iOS TOGGLE SWITCH — Refined
+ ========================================== */
+.ios-toggle {
+ position: relative;
+ display: inline-block;
+ width: 44px;
+ height: 26px;
+ flex-shrink: 0;
+}
+
+.ios-toggle input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+ position: absolute;
+}
+
+.ios-toggle .toggle-track {
+ position: absolute;
+ inset: 0;
+ background: rgba(120, 120, 128, .36);
+ border-radius: 13px;
+ cursor: pointer;
+ transition: background .3s var(--ease-spring);
+}
+
+.ios-toggle .toggle-track::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 22px;
+ height: 22px;
+ background: #fff;
+ border-radius: 50%;
+ transition: transform .3s var(--ease-spring), box-shadow .2s;
+ box-shadow: 0 2px 4px rgba(0,0,0,.25), 0 0 1px rgba(0,0,0,.15);
+}
+
+.ios-toggle input:checked + .toggle-track {
+ background: var(--accent-green);
+}
+
+.ios-toggle input:checked + .toggle-track::after {
+ transform: translateX(18px);
+ box-shadow: 0 2px 4px rgba(48,209,88,.3), 0 0 1px rgba(0,0,0,.1);
+}
+
+/* Small variant */
+.ios-toggle-sm {
+ width: 38px;
+ height: 22px;
+}
+
+.ios-toggle-sm .toggle-track {
+ border-radius: 11px;
+}
+
+.ios-toggle-sm .toggle-track::after {
+ width: 18px;
+ height: 18px;
+ top: 2px;
+ left: 2px;
+}
+
+.ios-toggle-sm input:checked + .toggle-track::after {
+ transform: translateX(16px);
+}
+
+.ios-toggle-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ cursor: pointer;
+ color: var(--text-primary);
+ user-select: none;
+}
+
+.toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.thread-count-wrap {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.thread-count-wrap input {
+ width: 52px;
+ padding: 4px 6px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 12px;
+ text-align: center;
+ outline: none;
+ font-family: 'JetBrains Mono', monospace;
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
+}
+
+.thread-count-wrap input:focus {
+ border-color: var(--accent-blue);
+ box-shadow: 0 0 0 3px var(--accent-blue-dim);
+}
+
+/* ==========================================
+ FORM INPUTS — iOS Style
+ ========================================== */
+.proxy-row {
+ display: flex;
+ gap: 5px;
+ margin-bottom: 6px;
+}
+
+.sidebar input[type="text"],
+.sidebar input[type="password"] {
+ font-size: 11px;
+ padding: 8px 10px;
+}
+
+.input-wrapper { flex: 1; position: relative; }
+
+input[type="text"],
+input[type="password"] {
+ width: 100%;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 12px;
+ padding: 9px 12px;
+ outline: none;
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast);
+ box-sizing: border-box;
+ min-width: 0;
+}
+
+input[type="text"]:focus,
+input[type="password"]:focus {
+ border-color: var(--accent-blue);
+ box-shadow: 0 0 0 3px var(--accent-blue-dim);
+ background: rgba(10,132,255,.03);
+}
+
+input::placeholder { color: var(--text-muted); }
+
+.proxy-status {
+ font-size: 11px;
+ padding: 7px 10px;
+ border-radius: var(--radius-sm);
+ background: var(--bg-card);
+ border: 1px solid var(--border-card);
+ color: var(--text-muted);
+ min-height: 30px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transition: all .3s var(--ease-spring);
+ margin-top: 4px;
+}
+.proxy-status.ok { color: var(--accent-green); background: var(--accent-green-dim); border-color: rgba(48,209,88,.2); }
+.proxy-status.fail { color: var(--accent-red); background: var(--accent-red-dim); border-color: rgba(255,69,58,.2); }
+
+/* ==========================================
+ BUTTONS — Flat iOS Style with Depth
+ ========================================== */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 8px 16px;
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ border: none;
+ transition: all var(--duration-fast) var(--ease-spring);
+ white-space: nowrap;
+ user-select: none;
+ font-family: inherit;
+ position: relative;
+ overflow: hidden;
+}
+
+.btn:active { transform: scale(.96); }
+.btn:disabled { opacity: .35; cursor: not-allowed; transform: none !important; }
+
+.btn-sm { padding: 6px 12px; font-size: 12px; }
+
+.btn-ghost {
+ background: var(--bg-card);
+ color: var(--text-secondary);
+ border: 1px solid var(--border-card);
+}
+.btn-ghost:hover {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border-color: rgba(255,255,255,.12);
+}
+
+.btn-primary {
+ background: var(--accent-blue);
+ color: #fff;
+ box-shadow: var(--shadow-button);
+}
+.btn-primary:hover { background: #409cff; box-shadow: var(--shadow-glow-blue); }
+
+.btn-danger {
+ background: var(--accent-red-dim);
+ color: var(--accent-red);
+ border: 1px solid rgba(255,69,58,.15);
+}
+.btn-danger:hover { background: rgba(255,69,58,.2); border-color: rgba(255,69,58,.25); }
+
+.btn-success {
+ background: var(--accent-green);
+ color: #fff;
+ box-shadow: var(--shadow-button);
+}
+.btn-success:hover { background: #3bdf66; box-shadow: var(--shadow-glow-green); }
+
+.control-buttons {
+ display: flex;
+ gap: 6px;
+}
+.control-buttons .btn {
+ flex: 1;
+ padding: 8px;
+ border-radius: var(--radius-md);
+ font-size: 13px;
+}
+.control-buttons .btn svg {
+ flex-shrink: 0;
+}
+
+.sidebar .btn-sm {
+ padding: 5px 10px;
+ font-size: 11px;
+}
+
+/* ==========================================
+ STATS CARDS — Subtle Glow
+ ========================================== */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ align-items: stretch;
+}
+
+.stat-card {
+ background: var(--bg-card);
+ border-radius: var(--radius-md);
+ padding: 10px 8px 9px;
+ text-align: center;
+ transition: all .2s var(--ease-spring);
+ border: 1px solid var(--border-card);
+ position: relative;
+ overflow: hidden;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+}
+.stat-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ border-radius: 2px 2px 0 0;
+ opacity: 0;
+ transition: opacity .2s;
+}
+.stat-card:hover { background: var(--bg-elevated); border-color: rgba(255,255,255,.1); }
+.stat-card:hover::before { opacity: 1; }
+.stat-card.span-2 { grid-column: auto; }
+
+.stat-value {
+ font-size: 20px;
+ font-weight: 800;
+ font-family: 'JetBrains Mono', monospace;
+ letter-spacing: -.6px;
+ line-height: .95;
+ white-space: nowrap;
+}
+.stat-value.green { color: var(--accent-green); }
+.stat-value.red { color: var(--accent-red); }
+.stat-value.blue { color: var(--accent-blue); }
+.stat-value.muted { color: var(--text-muted); }
+
+/* Top accent lines for stat cards */
+.stat-card:nth-child(1)::before { background: var(--accent-green); }
+.stat-card:nth-child(2)::before { background: var(--accent-red); }
+.stat-card:nth-child(3)::before { background: var(--accent-blue); }
+
+.stat-label {
+ font-size: 9px;
+ font-weight: 600;
+ color: var(--text-muted);
+ margin-top: 0;
+ text-transform: uppercase;
+ letter-spacing: .3px;
+ line-height: 1.1;
+}
+
+.progress-section-block {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.progress-section-block + .progress-section-block {
+ margin-top: 12px;
+}
+
+.progress-subtitle {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: .35px;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+}
+
+.progress-subtitle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.task-overview-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px;
+}
+
+.task-overview-card {
+ padding: 8px 10px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-card);
+ border-radius: var(--radius-md);
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-width: 0;
+}
+
+.task-overview-card.empty {
+ grid-column: 1 / -1;
+ color: var(--text-muted);
+}
+
+.task-overview-card.task-status-running,
+.task-overview-card.task-status-preparing {
+ border-color: rgba(10,132,255,.25);
+ background: linear-gradient(180deg, rgba(10,132,255,.12), rgba(10,132,255,.04));
+}
+
+.task-overview-card.task-status-stopping {
+ border-color: rgba(255,159,10,.25);
+ background: linear-gradient(180deg, rgba(255,159,10,.12), rgba(255,159,10,.04));
+}
+
+.task-overview-card.task-status-idle,
+.task-overview-card.task-status-meta {
+ border-color: var(--border-card);
+}
+
+.task-overview-label {
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: .35px;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+.task-overview-value {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-family: 'JetBrains Mono', monospace;
+ word-break: break-word;
+}
+
+.task-overview-hint {
+ font-size: 10px;
+ color: var(--text-secondary);
+}
+
+.worker-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-height: 228px;
+ overflow-y: auto;
+}
+
+.worker-card {
+ width: 100%;
+ border: 1px solid var(--border-card);
+ border-radius: var(--radius-md);
+ background: var(--bg-card);
+ padding: 8px 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: border-color .2s var(--ease-spring), transform .2s var(--ease-spring), background .2s var(--ease-spring);
+}
+
+.worker-card:hover {
+ border-color: var(--border-focused);
+ transform: translateY(-1px);
+}
+
+.worker-card.focused {
+ border-color: var(--accent-blue);
+ box-shadow: 0 0 0 1px rgba(10,132,255,.22);
+ background: linear-gradient(180deg, rgba(10,132,255,.12), rgba(10,132,255,.04));
+}
+
+.worker-card.empty {
+ cursor: default;
+ color: var(--text-muted);
+}
+
+.worker-card-head,
+.worker-card-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.worker-card-label {
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.worker-card-email {
+ font-size: 10px;
+ color: var(--text-secondary);
+ word-break: break-word;
+}
+
+.worker-card-meta {
+ font-size: 10px;
+ color: var(--text-secondary);
+}
+
+.worker-status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 8px;
+ border-radius: var(--radius-pill);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: .3px;
+ text-transform: uppercase;
+ border: 1px solid transparent;
+}
+
+.worker-status-badge.preparing,
+.worker-status-badge.running,
+.worker-status-badge.registering {
+ color: var(--accent-blue);
+ background: var(--accent-blue-dim);
+ border-color: rgba(10,132,255,.25);
+}
+
+.worker-status-badge.postprocessing,
+.worker-status-badge.waiting,
+.worker-status-badge.stopping {
+ color: var(--accent-orange);
+ background: var(--accent-orange-dim);
+ border-color: rgba(255,159,10,.25);
+}
+
+.worker-status-badge.error {
+ color: var(--accent-red);
+ background: var(--accent-red-dim);
+ border-color: rgba(255,69,58,.25);
+}
+
+.worker-status-badge.stopped,
+.worker-status-badge.idle {
+ color: var(--text-secondary);
+ background: rgba(255,255,255,.06);
+ border-color: var(--border-card);
+}
+
+.worker-detail-card {
+ padding: 10px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-card);
+ border-radius: var(--radius-md);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-height: 200px;
+ max-height: min(68vh, 760px);
+ overflow: hidden;
+}
+
+.worker-detail-card.empty {
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.worker-detail-running,
+.worker-detail-preparing,
+.worker-detail-registering {
+ border-color: rgba(10,132,255,.25);
+}
+
+.worker-detail-postprocessing,
+.worker-detail-waiting,
+.worker-detail-stopping {
+ border-color: rgba(255,159,10,.25);
+}
+
+.worker-detail-error {
+ border-color: rgba(255,69,58,.25);
+}
+
+.worker-detail-meta {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.worker-detail-meta-item {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px 12px;
+ background: rgba(255,255,255,.02);
+ border: 1px solid var(--border-card);
+ border-radius: var(--radius-sm);
+ min-width: 0;
+}
+
+.worker-detail-meta-item.wide {
+ grid-column: 1 / -1;
+}
+
+.worker-detail-meta-label {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: .35px;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+.worker-detail-meta-value {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+ word-break: break-word;
+}
+
+.worker-detail-steps {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-height: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.worker-detail-steps-title {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .35px;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+}
+
+.step-track-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ overflow-y: auto;
+ padding-right: 4px;
+}
+
+.step-track-item {
+ position: relative;
+ padding: 10px 12px 10px 16px;
+ border-radius: var(--radius-sm);
+ background: rgba(255,255,255,.02);
+ border: 1px solid var(--border-card);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.step-track-item::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 8px;
+ bottom: 8px;
+ width: 3px;
+ border-radius: 999px;
+ background: rgba(255,255,255,.08);
+}
+
+.step-track-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.step-track-label {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.step-track-badge {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: .3px;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+}
+
+.step-track-message,
+.step-track-time,
+.step-track-empty {
+ font-size: 11px;
+ color: var(--text-secondary);
+ word-break: break-word;
+}
+
+.step-status-active::before {
+ background: var(--accent-blue);
+}
+
+.step-status-done::before {
+ background: var(--accent-green);
+}
+
+.step-status-error::before {
+ background: var(--accent-red);
+}
+
+.step-status-pending::before,
+.step-status-skipped::before {
+ background: rgba(255,255,255,.18);
+}
+
+@media (max-width: 720px) {
+ .task-overview-grid,
+ .worker-detail-meta {
+ grid-template-columns: 1fr;
+ }
+
+ .progress-subtitle-row,
+ .worker-card-head,
+ .worker-card-row,
+ .step-track-head {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
+
+/* ==========================================
+ LOG PANEL (CENTER)
+ ========================================== */
+.main-area { display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
+.log-panel { display: flex; flex-direction: column; overflow: hidden; flex: 1; }
+
+.log-header {
+ padding: 8px 20px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-surface);
+ flex-shrink: 0;
+}
+
+.log-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+.log-title svg { color: var(--text-secondary); }
+
+.log-count-badge {
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--accent-blue);
+ background: var(--accent-blue-dim);
+ padding: 1px 8px;
+ border-radius: var(--radius-pill);
+ font-family: 'JetBrains Mono', monospace;
+}
+
+.log-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.log-autoscroll-label { font-size: 11px; gap: 6px; }
+.log-autoscroll-label span:last-child { color: var(--text-muted); font-weight: 500; }
+
+@media (max-width: 720px) {
+ .task-overview-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .progress-subtitle-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
+
+.log-body {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 8px 0;
+ background: var(--bg-base);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 12px;
+ line-height: 1.8;
+}
+
+.log-entry {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 2px 20px;
+ transition: background .1s;
+ border-left: 2px solid transparent;
+}
+.log-entry:hover { background: rgba(255,255,255,.02); }
+
+.log-placeholder {
+ color: var(--text-muted);
+ padding: 20px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 12px;
+ text-align: center;
+}
+
+.log-ts { color: var(--text-muted); flex-shrink: 0; font-size: 11px; margin-top: 2px; }
+.log-icon { flex-shrink: 0; font-size: 12px; margin-top: 1px; }
+.log-msg { color: var(--text-primary); word-break: break-all; flex: 1; min-width: 0; }
+.log-msg.info { color: var(--text-secondary); }
+.log-msg.success { color: var(--accent-green); }
+.log-msg.error { color: var(--accent-red); }
+.log-msg.warn { color: var(--accent-orange); }
+.log-msg.connected { color: var(--text-muted); font-style: italic; }
+
+/* Log entry left accent by type */
+.log-entry:has(.log-msg.success) { border-left-color: rgba(48,209,88,.3); }
+.log-entry:has(.log-msg.error) { border-left-color: rgba(255,69,58,.3); }
+.log-entry:has(.log-msg.warn) { border-left-color: rgba(255,159,10,.3); }
+
+.log-step {
+ font-size: 9px;
+ font-weight: 600;
+ color: var(--text-muted);
+ background: var(--bg-surface);
+ padding: 1px 6px;
+ border-radius: 4px;
+ flex-shrink: 0;
+ margin-top: 3px;
+}
+
+/* ==========================================
+ RIGHT DATA PANEL
+ ========================================== */
+.data-panel {
+ background: var(--bg-surface);
+ border-left: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-width: 0;
+}
+
+.remote-panel-wrap {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--bg-base);
+ border-bottom: 1px solid var(--border);
+}
+
+.data-panel-body {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.data-panel-section {
+ display: none;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.data-panel-section.active {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-gutter: stable;
+}
+
+#dataPanelSub2Api.data-panel-section.active {
+ overflow: hidden;
+}
+
+.pool-section-header {
+ padding: 8px 10px 5px;
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--text-muted);
+ letter-spacing: .4px;
+ text-transform: uppercase;
+ background: var(--bg-base);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.pool-section-title {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.pool-section-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 6px;
+ min-width: 0;
+ flex-wrap: wrap;
+}
+
+.pool-section-actions .inline-status {
+ flex: 1 1 100%;
+ text-align: right;
+ font-size: 11px;
+ padding: 0;
+}
+
+.pool-overview {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(62px, 1fr));
+ gap: 5px;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--separator);
+ background: var(--bg-surface);
+ flex-shrink: 0;
+}
+
+.pool-stat-card {
+ background: var(--bg-card);
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border-card);
+ padding: 5px 6px;
+ text-align: center;
+ min-width: 0;
+ transition: all .15s var(--ease-spring);
+}
+.pool-stat-card:hover { border-color: rgba(255,255,255,.1); }
+.pool-stat-card .stat-value { font-size: 15px; }
+.pool-stat-card .stat-label { font-size: 8px; }
+
+.remote-panel-wrap .btn-sm {
+ padding: 5px 9px;
+ font-size: 11px;
+}
+
+/* ---------- Token List ---------- */
+.pool-table-wrap {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.local-token-wrap {
+ flex: 1;
+ min-height: 0;
+ max-height: none;
+ background: var(--bg-surface);
+}
+
+.local-token-panel-section.active {
+ overflow: hidden;
+}
+
+.pool-table-wrap .token-list { padding: 6px 12px; }
+
+.sub2api-account-wrap {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-surface);
+ overflow: hidden;
+}
+
+.sub2api-account-wrap .sub2api-account-list {
+ flex: 1 1 auto;
+ min-height: 0;
+ max-height: none;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-gutter: stable;
+ padding: 6px 10px 8px;
+}
+
+.sub2api-account-wrap .token-filter-row,
+.sub2api-account-wrap .pool-table-footer {
+ flex-shrink: 0;
+}
+
+.pool-table-footer {
+ padding: 7px 10px;
+ border-top: 1px solid var(--separator);
+ background: var(--bg-surface);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.pool-table-footer .inline-status,
+.account-toolbar .inline-status {
+ flex: 1 1 100%;
+}
+
+.token-filter-row {
+ padding: 6px 10px;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ border-bottom: 1px solid var(--separator);
+ background: var(--bg-surface);
+ flex-wrap: wrap;
+}
+
+.account-toolbar {
+ align-items: center;
+}
+
+.account-select-all {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 28px;
+ font-size: 11px;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.account-select-all input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--accent-blue);
+ cursor: pointer;
+}
+
+.account-pager {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.pager-info {
+ font-size: 11px;
+ color: var(--text-secondary);
+ min-width: 0;
+ text-align: center;
+}
+
+.pager-size-select {
+ min-width: 76px;
+}
+
+.remote-panel-wrap .token-filter-select {
+ min-width: 86px;
+ padding: 5px 8px;
+ font-size: 11px;
+}
+
+.remote-panel-wrap input[type="text"] {
+ font-size: 11px;
+ padding: 7px 10px;
+}
+
+.platform-note-wrap {
+ flex: 1 1 auto;
+ min-height: 0;
+ padding: 10px;
+ overflow: auto;
+ background: var(--bg-surface);
+}
+
+.platform-note-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-card);
+ background: linear-gradient(180deg, rgba(255,255,255,.03) 0%, rgba(255,255,255,.015) 100%);
+}
+
+.platform-note-title {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.platform-note-text {
+ font-size: 12px;
+ line-height: 1.65;
+ color: var(--text-secondary);
+}
+
+.token-filter-select {
+ min-width: 100px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 12px;
+ padding: 6px 10px;
+ outline: none;
+ cursor: pointer;
+ font-family: inherit;
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
+}
+.token-filter-select:focus { border-color: var(--accent-blue); box-shadow: 0 0 0 3px var(--accent-blue-dim); }
+#tokenFilterKeyword { flex: 1; min-width: 120px; }
+
+.token-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px 12px;
+}
+
+.token-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 10px 12px;
+ border-radius: var(--radius-md);
+ background: var(--bg-card);
+ border: 1px solid var(--border-card);
+ margin-bottom: 6px;
+ transition: all .15s var(--ease-spring);
+ animation: fade-up .25s var(--ease-spring);
+}
+.token-item:hover {
+ background: var(--bg-elevated);
+ border-color: rgba(255,255,255,.1);
+ transform: translateY(-1px);
+}
+
+.sub2api-account-item {
+ gap: 8px;
+ padding: 8px 10px;
+ margin-bottom: 5px;
+}
+
+.sub2api-account-item.selected {
+ border-color: rgba(10,132,255,.45);
+ box-shadow: 0 0 0 1px var(--accent-blue-dim);
+}
+
+.sub2api-account-item .token-email {
+ font-size: 11px;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.sub2api-account-item .token-meta {
+ font-size: 9px;
+}
+
+.sub2api-account-item .token-actions {
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.sub2api-account-item .account-status-badge,
+.sub2api-account-item .account-flag-badge {
+ height: 16px;
+ padding: 0 6px;
+ font-size: 8px;
+}
+
+.account-check-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.account-check-wrap input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--accent-blue);
+ cursor: pointer;
+}
+
+@keyframes fade-up {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.token-info { flex: 1; min-width: 0; }
+.token-email {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+.token-email-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+.token-meta { font-size: 10px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
+.token-actions { display: flex; gap: 4px; flex-shrink: 0; }
+
+.account-status-badge,
+.account-flag-badge {
+ display: inline-flex;
+ align-items: center;
+ height: 18px;
+ padding: 0 7px;
+ border-radius: var(--radius-xs);
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: .3px;
+ white-space: nowrap;
+}
+
+.account-status-badge.ok {
+ color: var(--accent-green);
+ background: var(--accent-green-dim);
+}
+
+.account-status-badge.warn {
+ color: var(--accent-orange);
+ background: var(--accent-orange-dim);
+}
+
+.account-status-badge.danger {
+ color: var(--accent-red);
+ background: var(--accent-red-dim);
+}
+
+.account-flag-badge.duplicate {
+ color: var(--accent-blue);
+ background: var(--accent-blue-dim);
+}
+
+.account-flag-badge.keep {
+ color: var(--accent-green);
+ background: var(--accent-green-dim);
+}
+
+.account-flag-badge.delete {
+ color: var(--accent-orange);
+ background: var(--accent-orange-dim);
+}
+
+.synced-badge {
+ font-size: 9px;
+ font-weight: 700;
+ color: var(--accent-green);
+ background: var(--accent-green-dim);
+ padding: 2px 7px;
+ border-radius: var(--radius-pill);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.token-platforms { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin: 3px 0 2px; }
+
+.platform-badge {
+ display: inline-flex;
+ align-items: center;
+ height: 18px;
+ padding: 0 7px;
+ border-radius: var(--radius-xs);
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: .3px;
+ white-space: nowrap;
+}
+.platform-badge.cpa { color: var(--accent-orange); background: var(--accent-orange-dim); }
+.platform-badge.sub2api { color: var(--accent-green); background: var(--accent-green-dim); }
+.platform-badge.none { color: var(--text-muted); background: rgba(255,255,255,.04); }
+
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--text-muted);
+ gap: 10px;
+ font-size: 13px;
+ padding: 30px;
+}
+.empty-icon { opacity: .2; font-size: 32px; }
+
+/* ==========================================
+ TOAST NOTIFICATIONS — Refined
+ ========================================== */
+.toast-container {
+ position: fixed;
+ top: 64px;
+ right: 16px;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ pointer-events: none;
+}
+
+.toast {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-card);
+ border-radius: var(--radius-md);
+ padding: 10px 16px;
+ font-size: 13px;
+ font-weight: 500;
+ animation: toast-slide .3s var(--ease-spring);
+ max-width: 360px;
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ pointer-events: auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ box-shadow: var(--shadow-elevated);
+}
+
+.toast-icon {
+ flex-shrink: 0;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+@keyframes toast-slide {
+ from { opacity: 0; transform: translateX(16px) scale(.95); }
+ to { opacity: 1; transform: translateX(0) scale(1); }
+}
+
+@keyframes toast-out {
+ from { opacity: 1; transform: translateX(0) scale(1); }
+ to { opacity: 0; transform: translateX(16px) scale(.95); }
+}
+
+.toast.success { color: var(--accent-green); border-color: rgba(48,209,88,.2); }
+.toast.success .toast-icon { background: var(--accent-green-dim); color: var(--accent-green); }
+.toast.error { color: var(--accent-red); border-color: rgba(255,69,58,.2); }
+.toast.error .toast-icon { background: var(--accent-red-dim); color: var(--accent-red); }
+.toast.info { color: var(--accent-blue); border-color: rgba(10,132,255,.2); }
+.toast.info .toast-icon { background: var(--accent-blue-dim); color: var(--accent-blue); }
+
+/* ==========================================
+ CONFIG PAGE
+ ========================================== */
+.config-page {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 20px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ align-content: start;
+ background: var(--bg-base);
+}
+
+.config-card {
+ flex: 1 1 calc(50% - 8px);
+ min-width: 420px;
+ max-width: 100%;
+ background: var(--bg-surface);
+ border-radius: var(--radius-lg);
+ overflow: visible;
+ border: 1px solid var(--border-card);
+ transition: border-color .2s var(--ease-spring);
+}
+.config-card:hover { border-color: rgba(255,255,255,.1); }
+
+.config-page .config-card[style*="grid-column: span 2"],
+.config-page .config-card[style*="span 2"] {
+ flex-basis: 100% !important;
+ min-width: 100%;
+}
+
+.collapsible-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+ border-bottom: 1px solid var(--separator);
+ cursor: default;
+ pointer-events: none;
+ background: rgba(255,255,255,.02);
+}
+
+.collapse-icon { display: none; }
+
+.collapsible-body {
+ padding: 16px;
+ min-width: 0;
+}
+
+/* Config page: always show body */
+.config-page .collapsible-body { display: block !important; }
+
+.config-field { margin-bottom: 12px; }
+.config-field label {
+ display: block;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 5px;
+ letter-spacing: .3px;
+}
+
+.config-field select {
+ width: 100%;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 12px;
+ padding: 9px 12px;
+ outline: none;
+ cursor: pointer;
+ font-family: inherit;
+ -webkit-appearance: none;
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
+}
+.config-field select:focus { border-color: var(--accent-blue); box-shadow: 0 0 0 3px var(--accent-blue-dim); }
+
+.config-field input[type="number"] {
+ width: 100%;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 12px;
+ padding: 9px 12px;
+ outline: none;
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
+}
+.config-field input[type="number"]:focus { border-color: var(--accent-blue); box-shadow: 0 0 0 3px var(--accent-blue-dim); }
+
+.config-hint {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-top: 6px;
+ line-height: 1.6;
+ padding: 8px 10px;
+ background: rgba(255,255,255,.02);
+ border-radius: var(--radius-sm);
+ border-left: 2px solid var(--accent-blue-dim);
+}
+
+.config-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
+.config-row .config-field { min-width: 180px; }
+.config-actions { display: flex; gap: 8px; margin-top: 14px; align-items: center; flex-wrap: wrap; }
+
+.maintain-option-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.maintain-option {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 36px;
+ padding: 10px 12px;
+ border: 1px solid var(--border-card);
+ border-radius: var(--radius-sm);
+ background: var(--bg-card);
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.maintain-option input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--accent-blue);
+ cursor: pointer;
+}
+
+.inline-status {
+ font-size: 12px;
+ color: var(--text-secondary);
+ padding: 4px 0;
+ min-height: 20px;
+}
+
+.inline-status:empty {
+ display: none;
+}
+
+.config-status {
+ font-size: 12px;
+ margin-top: 8px;
+ padding: 6px 10px;
+ border-radius: var(--radius-sm);
+ background: var(--bg-card);
+ color: var(--text-muted);
+ min-height: 26px;
+ border: 1px solid var(--border-card);
+}
+
+/* ==========================================
+ MAIL PROVIDERS — iOS Grouped Style
+ ========================================== */
+.mail-providers-group { display: flex; flex-direction: column; gap: 6px; }
+
+.provider-item {
+ background: var(--bg-card);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 1px solid var(--border-card);
+ transition: border-color .2s;
+}
+.provider-item:has(.mail-provider-check:checked) { border-color: rgba(10,132,255,.2); }
+
+.provider-toggle {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-primary);
+ user-select: none;
+}
+
+.provider-toggle input[type="checkbox"] {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.provider-check-mark {
+ width: 20px;
+ height: 20px;
+ border-radius: 6px;
+ border: 2px solid rgba(255,255,255,.18);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: all .2s var(--ease-spring);
+ position: relative;
+}
+
+.provider-check-mark::after {
+ content: '';
+ width: 5px;
+ height: 9px;
+ border: solid #fff;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg) scale(0);
+ transition: transform .2s var(--ease-bounce);
+ position: absolute;
+ top: 2px;
+ left: 5px;
+}
+
+.provider-toggle input:checked ~ .provider-check-mark {
+ background: var(--accent-blue);
+ border-color: var(--accent-blue);
+}
+
+.provider-toggle input:checked ~ .provider-check-mark::after {
+ transform: rotate(45deg) scale(1);
+}
+
+.provider-config {
+ padding: 4px 12px 12px;
+ border-top: 1px solid var(--separator);
+}
+.provider-config .config-field { margin-top: 6px; }
+
+/* ==========================================
+ RESPONSIVE
+ ========================================== */
+@media (max-width: 1280px) {
+ .app-shell { --col-right: 280px; }
+ .pool-chip { min-width: 160px; }
+ .pool-stat-card { padding: 4px 6px; }
+ .pool-stat-card .stat-value { font-size: 15px; }
+ .config-card { min-width: 360px; }
+ .pool-chip { min-width: 136px; }
+}
+
+@media (max-width: 1100px) {
+ .theme-toggle-label { display: none; }
+ .theme-toggle-btn { min-width: 36px; padding: 0 8px; }
+}
+
+@media (max-width: 960px) {
+ header { padding: 0 12px; }
+ .header-tab-nav { padding: 0; }
+ .header-right { gap: 6px; }
+ .pool-chip { min-width: 124px; height: 42px; grid-template-rows: 12px 14px 3px; padding: 5px 8px 4px; }
+ .pool-chip-name { font-size: 9px; }
+ .pool-chip-value { font-size: 11px; }
+ .pool-chip-delta { font-size: 10px; padding: 2px 5px; }
+ .theme-toggle-btn { min-width: 34px; height: 30px; padding: 0 8px; font-size: 11px; gap: 6px; }
+ .token-filter-row { padding: 6px 10px; }
+ .header-tab-nav .segmented-control { width: auto; }
+ .header-tab-nav .tab-btn { min-width: 0; padding: 6px 14px; }
+}
+
+@media (max-width: 900px) {
+ .config-page { padding: 12px; }
+ .config-card { flex: 1 1 100%; min-width: 100%; }
+ .config-row .config-field { min-width: 0; flex: 1 1 100% !important; }
+}
+
+body.theme-light header {
+ background: rgba(255,255,255,.88);
+}
+
+body.theme-light .header-brand .logo {
+ background: linear-gradient(135deg, #111111 0%, #555555 100%);
+ box-shadow: none;
+}
+
+body.theme-light .segmented-control {
+ background: rgba(17,17,17,.10);
+}
+
+body.theme-light .segment-indicator {
+ box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 0 0 .5px rgba(0,0,0,.12);
+}
+
+body.theme-light .theme-toggle-btn:hover {
+ border-color: rgba(0,0,0,.18);
+}
+
+body.theme-light * {
+ scrollbar-color: rgba(0,0,0,.24) transparent;
+}
+
+body.theme-light ::-webkit-scrollbar-thumb {
+ background: rgba(0,0,0,.18);
+}
+
+body.theme-light ::-webkit-scrollbar-thumb:hover {
+ background: rgba(0,0,0,.28);
+}
+
+/* Helpers */
+.token-header .btn, .stats-grid, .stat-card, .control-buttons, .control-buttons .btn,
+.proxy-row, .log-header, .log-entry, .log-msg { min-width: 0; }
+
+/* Legacy compat — collapsible behavior kept for sidebar */
+.config-section { padding: 16px; border-bottom: none; }
+.collapsible .collapsible-body { display: none; }
+.collapsible.open .collapsible-body { display: block; }
+.collapsible.open .collapse-icon { transform: rotate(90deg); }
+.config-conditional { border-top: 1px dashed var(--separator); padding-top: 12px; margin-top: 4px; }
+
+/* Token panel (legacy compat) */
+.token-panel { display: flex; flex-direction: column; overflow: hidden; background: var(--bg-surface); border-left: 1px solid var(--border); min-width: 0; }
+.token-header { padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap; gap: 8px; }
+
+/* Multithread row (legacy compat) */
+.multithread-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 10px;
+}
+.multithread-row .toggle-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ cursor: pointer;
+ color: var(--text-secondary);
+}
+
+/* ==========================================
+ SELECTION & FOCUS — Global
+ ========================================== */
+::selection {
+ background: rgba(10,132,255,.3);
+ color: #fff;
+}
+
+/* Focus visible — accessibility ring */
+:focus-visible {
+ outline: 2px solid var(--accent-blue);
+ outline-offset: 2px;
+}
+
+button:focus-visible {
+ outline: 2px solid var(--accent-blue);
+ outline-offset: 2px;
+}
+
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100755
index 0000000..e26a46a
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,38 @@
+[project]
+name = "openai-pool-orchestrator"
+version = "2.0.0"
+description = "OpenAI 账号池编排器 — 自动化注册、Token 管理与多平台账号池维护"
+readme = "README.md"
+license = {text = "MIT"}
+requires-python = ">=3.10"
+keywords = ["openai", "account-pool", "automation", "token-management"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Software Development :: Libraries",
+]
+dependencies = [
+ "fastapi>=0.110",
+ "uvicorn[standard]>=0.27",
+ "curl-cffi>=0.6",
+ "aiohttp>=3.9",
+ "requests>=2.31",
+]
+
+[project.scripts]
+openai-pool = "openai_pool_orchestrator.__main__:main"
+
+[build-system]
+requires = ["setuptools>=68.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+include = ["openai_pool_orchestrator*"]
+
+[tool.setuptools.package-data]
+openai_pool_orchestrator = ["static/**/*"]
diff --git a/requirements.txt b/requirements.txt
new file mode 100755
index 0000000..28a8fcc
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+fastapi>=0.110
+uvicorn[standard]>=0.27
+curl-cffi>=0.6
+aiohttp>=3.9
+requests>=2.31
diff --git a/run.py b/run.py
new file mode 100755
index 0000000..e12c25d
--- /dev/null
+++ b/run.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+"""
+快速启动脚本 - OpenAI Pool Orchestrator
+
+用法:
+ python run.py # 启动 Web 服务
+ python run.py --cli # CLI 模式(单次注册)
+ python run.py --cli --proxy http://127.0.0.1:7897
+"""
+
+import sys
+import os
+
+# 将项目根目录加入 Python 路径
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+
+def main():
+ if "--cli" in sys.argv:
+ # CLI 模式:直接调用注册脚本
+ sys.argv.remove("--cli")
+ from openai_pool_orchestrator.register import main as cli_main
+ cli_main()
+ else:
+ # Web 模式:启动 FastAPI 服务
+ from openai_pool_orchestrator.__main__ import main as web_main
+ web_main()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/uv.lock b/uv.lock
new file mode 100755
index 0000000..0914e8f
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,1555 @@
+version = 1
+revision = 1
+requires-python = ">=3.10"
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.13.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "async-timeout", marker = "python_full_version < '3.11'" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950 },
+ { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099 },
+ { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072 },
+ { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588 },
+ { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334 },
+ { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656 },
+ { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625 },
+ { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604 },
+ { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370 },
+ { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023 },
+ { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680 },
+ { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407 },
+ { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047 },
+ { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264 },
+ { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275 },
+ { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053 },
+ { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687 },
+ { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051 },
+ { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234 },
+ { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979 },
+ { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297 },
+ { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172 },
+ { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405 },
+ { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449 },
+ { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444 },
+ { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038 },
+ { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156 },
+ { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340 },
+ { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041 },
+ { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024 },
+ { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590 },
+ { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355 },
+ { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701 },
+ { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678 },
+ { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732 },
+ { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293 },
+ { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533 },
+ { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839 },
+ { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932 },
+ { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906 },
+ { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020 },
+ { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181 },
+ { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794 },
+ { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900 },
+ { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239 },
+ { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527 },
+ { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489 },
+ { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852 },
+ { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379 },
+ { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253 },
+ { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407 },
+ { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190 },
+ { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783 },
+ { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704 },
+ { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652 },
+ { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014 },
+ { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777 },
+ { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276 },
+ { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131 },
+ { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863 },
+ { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793 },
+ { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676 },
+ { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217 },
+ { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303 },
+ { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673 },
+ { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120 },
+ { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383 },
+ { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899 },
+ { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238 },
+ { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292 },
+ { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021 },
+ { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263 },
+ { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107 },
+ { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196 },
+ { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591 },
+ { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277 },
+ { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575 },
+ { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455 },
+ { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417 },
+ { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968 },
+ { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690 },
+ { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390 },
+ { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188 },
+ { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126 },
+ { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128 },
+ { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512 },
+ { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444 },
+ { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798 },
+ { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835 },
+ { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486 },
+ { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951 },
+ { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001 },
+ { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246 },
+ { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131 },
+ { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196 },
+ { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841 },
+ { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193 },
+ { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979 },
+ { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193 },
+ { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801 },
+ { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523 },
+ { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694 },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 },
+]
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
+]
+
+[[package]]
+name = "anyio"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 },
+]
+
+[[package]]
+name = "async-timeout"
+version = "5.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 },
+]
+
+[[package]]
+name = "attrs"
+version = "25.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283 },
+ { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504 },
+ { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811 },
+ { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402 },
+ { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217 },
+ { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079 },
+ { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475 },
+ { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829 },
+ { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211 },
+ { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036 },
+ { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184 },
+ { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790 },
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 },
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 },
+ { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 },
+ { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 },
+ { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 },
+ { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 },
+ { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 },
+ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 },
+ { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 },
+ { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 },
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 },
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319 },
+ { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974 },
+ { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866 },
+ { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239 },
+ { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529 },
+ { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152 },
+ { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226 },
+ { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933 },
+ { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647 },
+ { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533 },
+ { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901 },
+ { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950 },
+ { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546 },
+ { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516 },
+ { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906 },
+ { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258 },
+ { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531 },
+ { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006 },
+ { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085 },
+ { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545 },
+ { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863 },
+ { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827 },
+ { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085 },
+ { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688 },
+ { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077 },
+ { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706 },
+ { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665 },
+ { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950 },
+ { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830 },
+ { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029 },
+ { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404 },
+ { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796 },
+ { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976 },
+ { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356 },
+ { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369 },
+ { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285 },
+ { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274 },
+ { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715 },
+ { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426 },
+ { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780 },
+ { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805 },
+ { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342 },
+ { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661 },
+ { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819 },
+ { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080 },
+ { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630 },
+ { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856 },
+ { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982 },
+ { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788 },
+ { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890 },
+ { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136 },
+ { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551 },
+ { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572 },
+ { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438 },
+ { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035 },
+ { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340 },
+ { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464 },
+ { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014 },
+ { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297 },
+ { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321 },
+ { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509 },
+ { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284 },
+ { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630 },
+ { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254 },
+ { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232 },
+ { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688 },
+ { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833 },
+ { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879 },
+ { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764 },
+ { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728 },
+ { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937 },
+ { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040 },
+ { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107 },
+ { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310 },
+ { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918 },
+ { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615 },
+ { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784 },
+ { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009 },
+ { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511 },
+ { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775 },
+ { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455 },
+]
+
+[[package]]
+name = "click"
+version = "8.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "curl-cffi"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277 },
+ { url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650 },
+ { url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918 },
+ { url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624 },
+ { url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654 },
+ { url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969 },
+ { url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133 },
+ { url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167 },
+ { url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464 },
+ { url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416 },
+ { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067 },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.135.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999 },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230 },
+ { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621 },
+ { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889 },
+ { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464 },
+ { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649 },
+ { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188 },
+ { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748 },
+ { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351 },
+ { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767 },
+ { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887 },
+ { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785 },
+ { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312 },
+ { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650 },
+ { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659 },
+ { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837 },
+ { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989 },
+ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912 },
+ { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046 },
+ { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119 },
+ { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067 },
+ { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160 },
+ { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544 },
+ { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797 },
+ { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923 },
+ { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886 },
+ { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731 },
+ { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544 },
+ { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806 },
+ { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382 },
+ { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647 },
+ { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064 },
+ { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937 },
+ { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782 },
+ { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594 },
+ { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448 },
+ { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411 },
+ { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014 },
+ { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909 },
+ { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049 },
+ { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485 },
+ { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619 },
+ { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320 },
+ { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820 },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518 },
+ { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096 },
+ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985 },
+ { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591 },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102 },
+ { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717 },
+ { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651 },
+ { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417 },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391 },
+ { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048 },
+ { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549 },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833 },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363 },
+ { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314 },
+ { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365 },
+ { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763 },
+ { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110 },
+ { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717 },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628 },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882 },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676 },
+ { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235 },
+ { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742 },
+ { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725 },
+ { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533 },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506 },
+ { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161 },
+ { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676 },
+ { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638 },
+ { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067 },
+ { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101 },
+ { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901 },
+ { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395 },
+ { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659 },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492 },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034 },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749 },
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127 },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698 },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749 },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298 },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015 },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038 },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130 },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845 },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131 },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542 },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308 },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210 },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972 },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536 },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330 },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627 },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238 },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738 },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739 },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186 },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196 },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830 },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289 },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318 },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814 },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762 },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470 },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042 },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148 },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676 },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451 },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507 },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
+]
+
+[[package]]
+name = "httptools"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531 },
+ { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408 },
+ { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889 },
+ { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460 },
+ { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267 },
+ { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429 },
+ { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173 },
+ { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521 },
+ { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375 },
+ { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621 },
+ { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954 },
+ { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175 },
+ { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310 },
+ { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875 },
+ { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280 },
+ { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004 },
+ { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655 },
+ { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440 },
+ { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186 },
+ { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192 },
+ { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694 },
+ { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 },
+ { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 },
+ { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 },
+ { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 },
+ { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 },
+ { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 },
+ { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 },
+ { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 },
+ { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 },
+ { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 },
+ { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 },
+ { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 },
+ { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 },
+ { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 },
+]
+
+[[package]]
+name = "multidict"
+version = "6.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176 },
+ { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996 },
+ { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631 },
+ { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561 },
+ { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223 },
+ { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322 },
+ { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005 },
+ { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173 },
+ { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273 },
+ { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956 },
+ { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477 },
+ { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615 },
+ { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930 },
+ { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807 },
+ { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103 },
+ { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416 },
+ { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022 },
+ { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238 },
+ { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626 },
+ { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706 },
+ { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356 },
+ { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355 },
+ { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433 },
+ { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376 },
+ { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365 },
+ { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747 },
+ { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293 },
+ { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962 },
+ { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360 },
+ { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940 },
+ { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502 },
+ { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065 },
+ { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870 },
+ { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302 },
+ { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981 },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159 },
+ { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893 },
+ { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456 },
+ { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872 },
+ { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018 },
+ { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883 },
+ { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413 },
+ { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404 },
+ { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456 },
+ { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322 },
+ { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955 },
+ { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254 },
+ { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059 },
+ { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588 },
+ { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642 },
+ { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377 },
+ { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887 },
+ { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053 },
+ { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307 },
+ { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174 },
+ { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116 },
+ { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524 },
+ { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368 },
+ { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952 },
+ { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317 },
+ { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132 },
+ { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140 },
+ { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277 },
+ { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291 },
+ { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156 },
+ { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742 },
+ { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221 },
+ { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664 },
+ { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490 },
+ { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695 },
+ { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884 },
+ { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122 },
+ { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175 },
+ { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460 },
+ { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930 },
+ { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582 },
+ { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031 },
+ { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596 },
+ { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492 },
+ { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899 },
+ { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970 },
+ { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060 },
+ { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888 },
+ { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554 },
+ { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341 },
+ { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391 },
+ { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422 },
+ { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770 },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109 },
+ { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573 },
+ { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190 },
+ { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486 },
+ { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219 },
+ { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132 },
+ { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420 },
+ { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510 },
+ { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094 },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786 },
+ { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483 },
+ { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403 },
+ { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315 },
+ { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528 },
+ { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784 },
+ { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980 },
+ { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602 },
+ { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930 },
+ { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074 },
+ { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471 },
+ { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401 },
+ { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143 },
+ { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507 },
+ { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358 },
+ { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884 },
+ { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878 },
+ { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542 },
+ { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403 },
+ { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889 },
+ { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982 },
+ { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415 },
+ { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337 },
+ { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788 },
+ { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842 },
+ { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237 },
+ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008 },
+ { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542 },
+ { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719 },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 },
+]
+
+[[package]]
+name = "openai-pool-orchestrator"
+version = "2.0.0"
+source = { editable = "." }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "curl-cffi" },
+ { name = "fastapi" },
+ { name = "requests" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aiohttp", specifier = ">=3.9" },
+ { name = "curl-cffi", specifier = ">=0.6" },
+ { name = "fastapi", specifier = ">=0.110" },
+ { name = "requests", specifier = ">=2.31" },
+ { name = "uvicorn", extras = ["standard"], specifier = ">=0.27" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534 },
+ { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526 },
+ { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263 },
+ { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012 },
+ { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491 },
+ { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319 },
+ { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856 },
+ { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241 },
+ { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552 },
+ { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113 },
+ { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778 },
+ { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047 },
+ { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093 },
+ { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638 },
+ { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229 },
+ { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208 },
+ { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777 },
+ { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647 },
+ { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929 },
+ { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778 },
+ { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144 },
+ { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030 },
+ { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252 },
+ { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064 },
+ { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429 },
+ { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727 },
+ { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097 },
+ { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084 },
+ { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637 },
+ { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064 },
+ { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061 },
+ { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037 },
+ { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324 },
+ { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505 },
+ { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242 },
+ { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474 },
+ { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575 },
+ { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736 },
+ { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019 },
+ { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376 },
+ { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988 },
+ { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615 },
+ { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066 },
+ { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655 },
+ { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789 },
+ { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750 },
+ { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780 },
+ { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308 },
+ { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182 },
+ { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215 },
+ { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112 },
+ { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442 },
+ { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398 },
+ { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920 },
+ { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748 },
+ { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877 },
+ { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437 },
+ { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586 },
+ { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790 },
+ { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158 },
+ { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451 },
+ { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374 },
+ { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396 },
+ { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950 },
+ { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856 },
+ { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420 },
+ { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254 },
+ { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205 },
+ { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873 },
+ { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739 },
+ { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514 },
+ { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781 },
+ { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396 },
+ { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897 },
+ { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789 },
+ { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152 },
+ { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869 },
+ { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596 },
+ { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981 },
+ { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490 },
+ { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371 },
+ { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424 },
+ { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566 },
+ { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130 },
+ { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625 },
+ { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209 },
+ { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797 },
+ { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140 },
+ { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257 },
+ { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097 },
+ { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455 },
+ { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372 },
+ { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411 },
+ { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712 },
+ { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557 },
+ { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015 },
+ { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880 },
+ { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938 },
+ { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641 },
+ { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510 },
+ { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161 },
+ { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393 },
+ { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546 },
+ { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259 },
+ { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428 },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298 },
+ { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475 },
+ { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815 },
+ { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567 },
+ { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442 },
+ { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956 },
+ { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253 },
+ { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050 },
+ { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178 },
+ { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833 },
+ { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156 },
+ { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378 },
+ { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622 },
+ { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 },
+ { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 },
+ { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 },
+ { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 },
+ { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 },
+ { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 },
+ { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 },
+ { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 },
+ { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 },
+ { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 },
+ { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 },
+ { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 },
+ { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 },
+ { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 },
+ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 },
+ { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 },
+ { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 },
+ { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 },
+ { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 },
+ { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 },
+ { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 },
+ { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 },
+ { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 },
+ { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 },
+ { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 },
+ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 },
+ { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 },
+ { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 },
+ { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 },
+ { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 },
+ { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 },
+ { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 },
+ { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 },
+ { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 },
+ { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 },
+ { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 },
+ { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 },
+ { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 },
+ { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 },
+ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 },
+ { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 },
+ { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 },
+ { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 },
+ { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 },
+ { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 },
+ { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 },
+ { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 },
+ { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 },
+ { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 },
+ { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 },
+ { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 },
+ { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 },
+ { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 },
+ { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 },
+ { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 },
+ { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 },
+ { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 },
+ { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 },
+ { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 },
+ { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 },
+ { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 },
+ { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 },
+ { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 },
+ { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 },
+ { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 },
+ { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 },
+ { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 },
+ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 },
+ { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 },
+ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 },
+ { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441 },
+ { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291 },
+ { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632 },
+ { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905 },
+ { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 },
+ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 },
+ { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 },
+ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 },
+ { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351 },
+ { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363 },
+ { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615 },
+ { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369 },
+ { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218 },
+ { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951 },
+ { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428 },
+ { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009 },
+ { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 },
+ { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 },
+ { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 },
+ { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 },
+ { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 },
+ { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 },
+ { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 },
+ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 },
+ { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 },
+ { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 },
+ { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 },
+ { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 },
+ { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 },
+ { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 },
+ { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 },
+ { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 },
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
+]
+
+[[package]]
+name = "starlette"
+version = "0.52.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.41.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783 },
+]
+
+[package.optional-dependencies]
+standard = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "httptools" },
+ { name = "python-dotenv" },
+ { name = "pyyaml" },
+ { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
+ { name = "watchfiles" },
+ { name = "websockets" },
+]
+
+[[package]]
+name = "uvloop"
+version = "0.22.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335 },
+ { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903 },
+ { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499 },
+ { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133 },
+ { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681 },
+ { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261 },
+ { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420 },
+ { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677 },
+ { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819 },
+ { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529 },
+ { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267 },
+ { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105 },
+ { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936 },
+ { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769 },
+ { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413 },
+ { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307 },
+ { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970 },
+ { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343 },
+ { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 },
+ { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 },
+ { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 },
+ { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 },
+ { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 },
+ { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 },
+ { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 },
+ { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 },
+ { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 },
+ { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 },
+ { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 },
+ { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 },
+ { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 },
+ { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 },
+ { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 },
+ { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 },
+ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 },
+]
+
+[[package]]
+name = "watchfiles"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318 },
+ { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478 },
+ { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894 },
+ { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065 },
+ { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377 },
+ { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837 },
+ { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456 },
+ { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614 },
+ { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690 },
+ { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459 },
+ { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663 },
+ { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453 },
+ { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529 },
+ { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384 },
+ { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789 },
+ { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521 },
+ { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722 },
+ { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088 },
+ { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923 },
+ { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080 },
+ { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432 },
+ { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046 },
+ { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473 },
+ { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598 },
+ { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210 },
+ { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745 },
+ { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769 },
+ { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374 },
+ { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485 },
+ { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813 },
+ { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816 },
+ { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186 },
+ { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812 },
+ { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196 },
+ { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657 },
+ { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042 },
+ { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410 },
+ { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209 },
+ { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 },
+ { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 },
+ { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 },
+ { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 },
+ { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 },
+ { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 },
+ { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 },
+ { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 },
+ { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 },
+ { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 },
+ { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 },
+ { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 },
+ { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 },
+ { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 },
+ { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 },
+ { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 },
+ { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 },
+ { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 },
+ { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 },
+ { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 },
+ { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 },
+ { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 },
+ { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 },
+ { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 },
+ { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 },
+ { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 },
+ { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 },
+ { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 },
+ { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 },
+ { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 },
+ { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 },
+ { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 },
+ { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 },
+ { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 },
+ { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 },
+ { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 },
+ { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 },
+ { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 },
+ { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 },
+ { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 },
+ { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 },
+ { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 },
+ { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 },
+ { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 },
+ { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611 },
+ { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889 },
+ { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616 },
+ { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413 },
+ { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250 },
+ { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117 },
+ { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493 },
+ { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546 },
+]
+
+[[package]]
+name = "websockets"
+version = "16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343 },
+ { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021 },
+ { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320 },
+ { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815 },
+ { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054 },
+ { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565 },
+ { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848 },
+ { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249 },
+ { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685 },
+ { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340 },
+ { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022 },
+ { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319 },
+ { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631 },
+ { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870 },
+ { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361 },
+ { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615 },
+ { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246 },
+ { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684 },
+ { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365 },
+ { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038 },
+ { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328 },
+ { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915 },
+ { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152 },
+ { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583 },
+ { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880 },
+ { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261 },
+ { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693 },
+ { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364 },
+ { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039 },
+ { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323 },
+ { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975 },
+ { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203 },
+ { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653 },
+ { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920 },
+ { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255 },
+ { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689 },
+ { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406 },
+ { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085 },
+ { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328 },
+ { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044 },
+ { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279 },
+ { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711 },
+ { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982 },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915 },
+ { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381 },
+ { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737 },
+ { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268 },
+ { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486 },
+ { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331 },
+ { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501 },
+ { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062 },
+ { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356 },
+ { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085 },
+ { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 },
+ { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947 },
+ { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260 },
+ { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071 },
+ { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968 },
+ { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735 },
+ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 },
+]
+
+[[package]]
+name = "yarl"
+version = "1.23.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764 },
+ { url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282 },
+ { url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053 },
+ { url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395 },
+ { url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143 },
+ { url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643 },
+ { url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700 },
+ { url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769 },
+ { url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114 },
+ { url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883 },
+ { url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172 },
+ { url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010 },
+ { url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285 },
+ { url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230 },
+ { url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008 },
+ { url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073 },
+ { url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328 },
+ { url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463 },
+ { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641 },
+ { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248 },
+ { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988 },
+ { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566 },
+ { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079 },
+ { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741 },
+ { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099 },
+ { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678 },
+ { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803 },
+ { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163 },
+ { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859 },
+ { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202 },
+ { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866 },
+ { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852 },
+ { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919 },
+ { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602 },
+ { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461 },
+ { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336 },
+ { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737 },
+ { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029 },
+ { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310 },
+ { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587 },
+ { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528 },
+ { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339 },
+ { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061 },
+ { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132 },
+ { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289 },
+ { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950 },
+ { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960 },
+ { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703 },
+ { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325 },
+ { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067 },
+ { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285 },
+ { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359 },
+ { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674 },
+ { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879 },
+ { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796 },
+ { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547 },
+ { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854 },
+ { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351 },
+ { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711 },
+ { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014 },
+ { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557 },
+ { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559 },
+ { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502 },
+ { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027 },
+ { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369 },
+ { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565 },
+ { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813 },
+ { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632 },
+ { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895 },
+ { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356 },
+ { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515 },
+ { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785 },
+ { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719 },
+ { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690 },
+ { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851 },
+ { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874 },
+ { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710 },
+ { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033 },
+ { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817 },
+ { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482 },
+ { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949 },
+ { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839 },
+ { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696 },
+ { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865 },
+ { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234 },
+ { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295 },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784 },
+ { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313 },
+ { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932 },
+ { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786 },
+ { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455 },
+ { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752 },
+ { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291 },
+ { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026 },
+ { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355 },
+ { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417 },
+ { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422 },
+ { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915 },
+ { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690 },
+ { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750 },
+ { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685 },
+ { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009 },
+ { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033 },
+ { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483 },
+ { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175 },
+ { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871 },
+ { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093 },
+ { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384 },
+ { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019 },
+ { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894 },
+ { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979 },
+ { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943 },
+ { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786 },
+ { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307 },
+ { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904 },
+ { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728 },
+ { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964 },
+ { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882 },
+ { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797 },
+ { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023 },
+ { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227 },
+ { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302 },
+ { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202 },
+ { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558 },
+ { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610 },
+ { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041 },
+ { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288 },
+]